Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 8 additions & 15 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,21 +1,14 @@
# TaskGenie Configuration
# Copy this file to .env and edit as needed

# App metadata
APP_NAME=TaskGenie
APP_VERSION=0.1.0
DEBUG=true
DEBUG=false

# Server
HOST=127.0.0.1
PORT=8080

DATABASE_URL=sqlite+aiosqlite:///./data/taskgenie.db

LLM_PROVIDER=openrouter
LLM_API_KEY=
LLM_MODEL=anthropic/claude-3-haiku

GMAIL_ENABLED=false
GMAIL_CREDENTIALS_PATH=

GITHUB_TOKEN=
GITHUB_USERNAME=

NOTIFICATIONS_ENABLED=true
NOTIFICATION_SCHEDULE=["24h", "6h"]
# Database (optional - defaults to ~/.taskgenie/data/taskgenie.db)
# DATABASE_URL=sqlite+aiosqlite:///./data/taskgenie.db
3 changes: 0 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,5 @@ jobs:
- name: Install dependencies
run: uv pip install -e ".[dev]"

- name: Lint
run: make lint

- name: Tests
run: make test-cov
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ help:
@echo " make format Run ruff formatter"
@echo " make typecheck Run mypy"
@echo " make test Run pytest"
@echo " make test-cov Run pytest with coverage"
@echo " make test-cov Run precommit + pytest with coverage (matches CI)"
@echo " make docs-check Validate docs links/naming"
@echo " make check Run lint + typecheck + test"

Expand Down Expand Up @@ -41,7 +41,7 @@ typecheck:
test:
uv run pytest -n 4

test-cov:
test-cov: precommit
uv run pytest -n 4 --cov=backend --cov-report=term-missing

docs-check:
Expand Down
7 changes: 7 additions & 0 deletions backend/api/v1/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""API v1 package.

Author:
Raymond Christopher (raymond.christopher@gdplabs.id)
"""

__all__: list[str] = []
118 changes: 118 additions & 0 deletions backend/api/v1/tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""Task API endpoints.

Author:
Raymond Christopher (raymond.christopher@gdplabs.id)
"""

from __future__ import annotations

from datetime import datetime

from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession

from backend.database import get_db
from backend.schemas.task import TaskCreate, TaskListResponse, TaskPriority, TaskResponse, TaskStatus, TaskUpdate
from backend.services.task_service import create_task, delete_task, get_task, list_tasks, update_task

router = APIRouter(prefix="/tasks", tags=["tasks"])


@router.post("", response_model=TaskResponse, status_code=status.HTTP_201_CREATED)
async def create_task_endpoint(task_data: TaskCreate, session: AsyncSession = Depends(get_db)) -> TaskResponse:
"""Create a new task.

Args:
task_data: Task creation data.
session: Database session.

Returns:
TaskResponse: Created task.
"""
return await create_task(session, task_data)


@router.get("", response_model=TaskListResponse)
async def list_tasks_endpoint(
status: TaskStatus | None = Query(None, description="Filter by status"),
priority: TaskPriority | None = Query(None, description="Filter by priority"),
due_before: datetime | None = Query(None, description="Filter tasks due before this datetime"),
due_after: datetime | None = Query(None, description="Filter tasks due after this datetime"),
limit: int = Query(50, ge=1, description="Maximum number of results"),
offset: int = Query(0, ge=0, description="Pagination offset"),
session: AsyncSession = Depends(get_db),
) -> TaskListResponse:
"""List tasks with optional filters and pagination.

Args:
status: Filter by status (pending, in_progress, completed).
priority: Filter by priority (low, medium, high, critical).
due_before: Filter tasks with eta before this datetime.
due_after: Filter tasks with eta after this datetime.
limit: Maximum number of results (default: 50).
offset: Pagination offset (default: 0).
session: Database session.

Returns:
TaskListResponse: Paginated task list.
"""
return await list_tasks(
session=session,
status=status.value if status else None,
priority=priority.value if priority else None,
due_before=due_before,
due_after=due_after,
limit=limit,
offset=offset,
)


@router.get("/{task_id}", response_model=TaskResponse)
async def get_task_endpoint(task_id: str, session: AsyncSession = Depends(get_db)) -> TaskResponse:
"""Get a task by ID.

Args:
task_id: Task ID.
session: Database session.

Returns:
TaskResponse: Task data.

Raises:
TaskNotFoundError: If task is not found (handled by FastAPI exception handler).
"""
return await get_task(session, task_id)


@router.patch("/{task_id}", response_model=TaskResponse)
async def update_task_endpoint(
task_id: str, task_data: TaskUpdate, session: AsyncSession = Depends(get_db)
) -> TaskResponse:
"""Update a task.

Args:
task_id: Task ID.
task_data: Task update data.
session: Database session.

Returns:
TaskResponse: Updated task.

Raises:
TaskNotFoundError: If task is not found (handled by FastAPI exception handler).
"""
return await update_task(session, task_id, task_data)


@router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_task_endpoint(task_id: str, session: AsyncSession = Depends(get_db)) -> None:
"""Delete a task.

Args:
task_id: Task ID.
session: Database session.

Raises:
TaskNotFoundError: If task is not found (handled by FastAPI exception handler).
"""
await delete_task(session, task_id)
24 changes: 23 additions & 1 deletion backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@
from contextlib import asynccontextmanager

import uvicorn
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

from backend.api.v1 import tasks as tasks_router
from backend.config import get_settings
from backend.database import close_db, init_db_async
from backend.schemas.task import ErrorResponse
from backend.services.task_service import TaskNotFoundError


@asynccontextmanager
Expand All @@ -30,6 +34,24 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings = get_settings()
app = FastAPI(title=settings.app_name, version=settings.app_version, lifespan=lifespan)

# Register API routers
app.include_router(tasks_router.router, prefix="/api/v1")


@app.exception_handler(TaskNotFoundError)
async def task_not_found_handler(request: Request, exc: TaskNotFoundError) -> JSONResponse:
"""Handle TaskNotFoundError exceptions.

Args:
request: FastAPI request object.
exc: TaskNotFoundError exception.

Returns:
JSONResponse: 404 response with error details.
"""
error_response = ErrorResponse(error="Task not found", code=exc.code)
return JSONResponse(status_code=404, content=error_response.model_dump())


@app.get("/health")
async def health_check() -> dict[str, str]:
Expand Down
110 changes: 110 additions & 0 deletions backend/schemas/task.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"""Task schemas for API request/response validation.

Author:
Raymond Christopher (raymond.christopher@gdplabs.id)
"""

from __future__ import annotations

from datetime import datetime
from enum import Enum
from typing import Any

from pydantic import BaseModel, ConfigDict, Field, model_validator


class TaskStatus(str, Enum):
"""Task status enum."""

PENDING = "pending"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"


class TaskPriority(str, Enum):
"""Task priority enum."""

LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"


class TaskCreate(BaseModel):
"""Schema for creating a task."""

title: str = Field(..., min_length=1, max_length=255)
description: str | None = None
status: TaskStatus = TaskStatus.PENDING
priority: TaskPriority = TaskPriority.MEDIUM
eta: datetime | None = None
tags: list[str] | None = None
metadata: dict[str, Any] | None = None


class TaskUpdate(BaseModel):
"""Schema for updating a task (all fields optional)."""

title: str | None = Field(default=None, min_length=1, max_length=255)
description: str | None = None
status: TaskStatus | None = None
priority: TaskPriority | None = None
eta: datetime | None = None
tags: list[str] | None = None
metadata: dict[str, Any] | None = None

@model_validator(mode="before")
@classmethod
def validate_title_not_null(cls, data: dict[str, Any] | Any) -> dict[str, Any] | Any:
"""Reject title=None to prevent database integrity errors.

This validator runs before Pydantic's type coercion, so it can detect
when title is explicitly set to None in the input JSON.

Args:
data: Raw input data (dict or model instance).

Returns:
Data unchanged if valid.

Raises:
ValueError: If title is explicitly set to None.
"""
if isinstance(data, dict) and "title" in data and data["title"] is None:
msg = "title cannot be null"
raise ValueError(msg)
return data


class TaskResponse(BaseModel):
"""Schema for task response."""

model_config = ConfigDict(from_attributes=True, populate_by_name=True)

id: str
title: str
description: str | None
status: TaskStatus
priority: TaskPriority
eta: datetime | None
created_at: datetime
updated_at: datetime
tags: list[str] | None = None
metadata: dict[str, Any] | None = Field(None, alias="meta_data", serialization_alias="metadata")
attachments: list[dict[str, Any]] = Field(default_factory=list)


class TaskListResponse(BaseModel):
"""Schema for paginated task list response."""

tasks: list[TaskResponse]
total: int
page: int
page_size: int


class ErrorResponse(BaseModel):
"""Standard error response schema."""

error: str
code: str
4 changes: 4 additions & 0 deletions backend/services/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,7 @@
Author:
Raymond Christopher (raymond.christopher@gdplabs.id)
"""

from backend.services.task_service import TaskNotFoundError, create_task, delete_task, get_task, list_tasks, update_task

__all__: list[str] = ["TaskNotFoundError", "create_task", "delete_task", "get_task", "list_tasks", "update_task"]
Loading