feat: Add API endpoint for managing categories

This commit is contained in:
2026-07-03 08:33:48 +05:30
parent be6c95c497
commit 8488e3bdd0
15 changed files with 413 additions and 48 deletions

35
app/api/v1/categories.py Normal file
View File

@@ -0,0 +1,35 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from app.core.dependencies import get_category_service
from app.core.exceptions import CategoryAlreadyExistsException
from app.schemas.category import CategoryCreate, CategoryResponse
from app.services.category_service import CategoryService
router = APIRouter(prefix="/categories", tags=["categories"])
@router.get("", response_model=List[CategoryResponse])
async def list_categories(
service: CategoryService = Depends(get_category_service),
):
"""
Returns the list of all available categories ordered alphabetically.
"""
return await service.list_categories()
@router.post("", response_model=CategoryResponse, status_code=status.HTTP_201_CREATED)
async def create_category(
category_in: CategoryCreate,
service: CategoryService = Depends(get_category_service),
):
"""
Creates a new custom category. The name must be unique.
"""
try:
return await service.create_category(name=category_in.name)
except CategoryAlreadyExistsException as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)

View File

@@ -3,6 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db
from app.repositories.job_repository import JobRepository
from app.services.job_service import JobService
from app.services.category_service import CategoryService
def get_job_repository(db: AsyncSession = Depends(get_db)) -> JobRepository:
@@ -13,3 +14,8 @@ def get_job_repository(db: AsyncSession = Depends(get_db)) -> JobRepository:
def get_job_service(repo: JobRepository = Depends(get_job_repository)) -> JobService:
"""Dependency to retrieve JobService"""
return JobService(repo)
def get_category_service(db: AsyncSession = Depends(get_db)) -> CategoryService:
"""Dependency to retrieve CategoryService"""
return CategoryService(db)

View File

@@ -18,3 +18,11 @@ class InvalidCSVException(AlemnoException):
def __init__(self, detail: str):
self.detail = detail
super().__init__(detail)
class CategoryAlreadyExistsException(AlemnoException):
"""Raised when a category already exists"""
def __init__(self, name: str):
self.name = name
super().__init__(f"Category '{name}' already exists.")

View File

@@ -2,5 +2,6 @@ from app.db.base import Base
from app.db.models.job import Job
from app.db.models.transaction import Transaction
from app.db.models.job_summary import JobSummary
from app.db.models.category import Category
__all__ = ["Base", "Job", "Transaction", "JobSummary"]
__all__ = ["Base", "Job", "Transaction", "JobSummary", "Category"]

10
app/db/models/category.py Normal file
View File

@@ -0,0 +1,10 @@
from sqlalchemy import Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class Category(Base):
__tablename__ = "categories"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)

View File

@@ -2,6 +2,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.v1.jobs import router as jobs_router
from app.api.v1.categories import router as categories_router
from app.core.config import settings
from app.db.base import Base
from app.db.session import engine
@@ -12,6 +13,28 @@ async def lifespan(app: FastAPI):
# Startup: Create tables if they don't exist
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Seed default categories if empty
from app.db.session import AsyncSessionLocal
from app.db.models.category import Category
from sqlalchemy import select
async with AsyncSessionLocal() as session:
result = await session.execute(select(Category))
if not result.scalars().first():
defaults = [
Category(name="Food"),
Category(name="Shopping"),
Category(name="Travel"),
Category(name="Transport"),
Category(name="Utilities"),
Category(name="Cash Withdrawal"),
Category(name="Entertainment"),
Category(name="Other"),
]
session.add_all(defaults)
await session.commit()
yield
# Shutdown: Clean up pool
await engine.dispose()
@@ -32,8 +55,9 @@ app.add_middleware(
allow_headers=["*"],
)
# Include API Router
# Include API Routers
app.include_router(jobs_router, prefix=settings.API_V1_STR)
app.include_router(categories_router, prefix=settings.API_V1_STR)
# Root healthcheck endpoint

View File

@@ -0,0 +1,28 @@
from typing import List, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models.category import Category
class CategoryRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def list_all(self) -> List[Category]:
"""List all category records ordered alphabetically by name"""
query = select(Category).order_by(Category.name.asc())
result = await self.db.execute(query)
return list(result.scalars().all())
async def create(self, name: str) -> Category:
"""Create a new category record"""
category = Category(name=name)
self.db.add(category)
await self.db.flush()
return category
async def get_by_name(self, name: str) -> Optional[Category]:
"""Fetch a category record by name (case-insensitive)"""
query = select(Category).where(Category.name.ilike(name))
result = await self.db.execute(query)
return result.scalar_one_or_none()

17
app/schemas/category.py Normal file
View File

@@ -0,0 +1,17 @@
from pydantic import BaseModel, Field, ConfigDict
class CategoryBase(BaseModel):
name: str = Field(
..., description="Unique category name", min_length=1, max_length=100
)
class CategoryCreate(CategoryBase):
pass
class CategoryResponse(CategoryBase):
id: int
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,26 @@
from typing import List
from sqlalchemy.ext.asyncio import AsyncSession
from app.repositories.category_repository import CategoryRepository
from app.db.models.category import Category
from app.core.exceptions import CategoryAlreadyExistsException
class CategoryService:
def __init__(self, db: AsyncSession):
self.db = db
self.repo = CategoryRepository(db)
async def list_categories(self) -> List[Category]:
"""Returns all categories alphabetical list"""
return await self.repo.list_all()
async def create_category(self, name: str) -> Category:
"""Creates a new category, validating uniqueness first"""
name_clean = name.strip()
existing = await self.repo.get_by_name(name_clean)
if existing:
raise CategoryAlreadyExistsException(name_clean)
category = await self.repo.create(name_clean)
await self.db.commit()
return category

View File

@@ -83,6 +83,7 @@ def parse_and_validate_csv(content: bytes) -> List[Dict[str, Any]]:
# Auto-generate txn_id if missing or empty
if not txn_id or not txn_id.strip():
import uuid
txn_id = f"GEN_{uuid.uuid4().hex[:8].upper()}"
else:
txn_id = txn_id.strip()

View File

@@ -24,7 +24,7 @@ class TransactionClassification(BaseModel):
description="The unique transaction ID (txn_id) from the input."
)
category: str = Field(
description="Assigned category, must be one of: Food, Shopping, Travel, Transport, Utilities, Cash Withdrawal, Entertainment, or Other."
description="Assigned category matching one of the allowed categories list."
)
@@ -35,6 +35,7 @@ class BatchClassificationResponse(BaseModel):
async def classify_transactions_batch(
client: genai.Client,
batch_txns: List[Dict[str, Any]],
allowed_categories: List[str],
) -> Dict[str, Dict[str, Any]]:
"""
Calls the Gemini model to classify a batch of transactions.
@@ -55,8 +56,7 @@ async def classify_transactions_batch(
input_prompt = (
"Classify the following financial transactions. For each transaction, match its txn_id "
"and assign one of the allowed categories: Food, Shopping, Travel, Transport, Utilities, "
"Cash Withdrawal, Entertainment, or Other based on the merchant name and details:\n\n"
f"and assign one of the allowed categories: {', '.join(allowed_categories)} based on the merchant name and details:\n\n"
f"{json.dumps(prompt_data, indent=2)}"
)
@@ -146,6 +146,9 @@ def process_transaction_job(job_id: str, transactions: List[Dict[str, Any]]):
async def _process_job_async(job_id: str, transactions: List[Dict[str, Any]]):
from app.db.session import engine
await engine.dispose()
async with AsyncSessionLocal() as db:
repo = JobRepository(db)
job = await repo.get_by_id(job_id)
@@ -215,6 +218,28 @@ async def _process_job_async(job_id: str, transactions: List[Dict[str, Any]]):
llm_results = {}
api_key = settings.GEMINI_API_KEY or os.environ.get("GEMINI_API_KEY")
if uncategorized_txns:
# Load allowed categories from the database categories table
from app.db.models.category import Category
try:
res_cats = await db.execute(select(Category.name))
allowed_categories = [r[0] for r in res_cats.all()]
except Exception as db_err:
logger.warning(f"Could not load categories from database: {db_err}")
allowed_categories = []
if not allowed_categories:
allowed_categories = [
"Food",
"Shopping",
"Travel",
"Transport",
"Utilities",
"Cash Withdrawal",
"Entertainment",
"Other",
]
if api_key:
logger.info(
f"Found {len(uncategorized_txns)} uncategorized transactions. Initializing GenAI client..."
@@ -226,7 +251,7 @@ async def _process_job_async(job_id: str, transactions: List[Dict[str, Any]]):
for i in range(0, len(uncategorized_txns), batch_size):
batch = uncategorized_txns[i : i + batch_size]
batch_results = await classify_transactions_batch(
client, batch
client, batch, allowed_categories
)
llm_results.update(batch_results)
except Exception as e:
@@ -247,58 +272,92 @@ async def _process_job_async(job_id: str, transactions: List[Dict[str, Any]]):
# Mock categorization mapping based on merchant name keywords
for t in uncategorized_txns:
merchant_lower = t["merchant"].lower()
cat = "Other"
if any(
kw in merchant_lower
for kw in ["swiggy", "zomato", "restaurant", "cafe", "food"]
cat = (
"Other"
if "Other" in allowed_categories
else (
allowed_categories[0] if allowed_categories else "Other"
)
)
if (
any(
kw in merchant_lower
for kw in [
"swiggy",
"zomato",
"restaurant",
"cafe",
"food",
]
)
and "Food" in allowed_categories
):
cat = "Food"
elif any(
kw in merchant_lower
for kw in ["ola", "uber", "taxi", "metro", "train"]
elif (
any(
kw in merchant_lower
for kw in ["ola", "uber", "taxi", "metro", "train"]
)
and "Transport" in allowed_categories
):
cat = "Transport"
elif any(
kw in merchant_lower
for kw in [
"amazon",
"flipkart",
"shopping",
"store",
"supermarket",
]
elif (
any(
kw in merchant_lower
for kw in [
"amazon",
"flipkart",
"shopping",
"store",
"supermarket",
]
)
and "Shopping" in allowed_categories
):
cat = "Shopping"
elif any(
kw in merchant_lower
for kw in ["air", "flight", "hotel", "travel", "irctc"]
elif (
any(
kw in merchant_lower
for kw in ["air", "flight", "hotel", "travel", "irctc"]
)
and "Travel" in allowed_categories
):
cat = "Travel"
elif any(
kw in merchant_lower
for kw in [
"electric",
"power",
"water",
"bill",
"utility",
"utilities",
]
elif (
any(
kw in merchant_lower
for kw in [
"electric",
"power",
"water",
"bill",
"utility",
"utilities",
]
)
and "Utilities" in allowed_categories
):
cat = "Utilities"
elif any(
kw in merchant_lower for kw in ["atm", "cash", "withdrawal"]
elif (
any(
kw in merchant_lower
for kw in ["atm", "cash", "withdrawal"]
)
and "Cash Withdrawal" in allowed_categories
):
cat = "Cash Withdrawal"
elif any(
kw in merchant_lower
for kw in [
"netflix",
"spotify",
"movie",
"show",
"entertainment",
]
elif (
any(
kw in merchant_lower
for kw in [
"netflix",
"spotify",
"movie",
"show",
"entertainment",
]
)
and "Entertainment" in allowed_categories
):
cat = "Entertainment"