mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
feat: idle cash alert and category hierarchy (subcategories)
Two of the four ideas picked from the Maybe/Sure feature audit: - Idle cash: a 5th proactive alert flags a depository account (checking/ savings/cash) sitting at or above a configurable balance with no transaction touching it for a configurable number of days. - Category hierarchy: categories can now have one level of subcategories (Groceries -> Alcohol). Dashboard/report aggregations roll a subcategory's spending into its parent's slice. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,21 @@ from app.schemas.category import CategoryCreate, CategoryRead, CategoryUpdate
|
||||
router = APIRouter(prefix="/categories", tags=["categories"])
|
||||
|
||||
|
||||
async def _validate_parent(session: AsyncSession, parent_id: int, kind: CategoryKind, category_id: int | None) -> None:
|
||||
"""Subcategories are one level deep only: a parent must itself be
|
||||
top-level, and must share the child's kind (an expense category can't
|
||||
nest under an income one, or vice versa)."""
|
||||
if parent_id == category_id:
|
||||
raise HTTPException(status_code=400, detail="A category cannot be its own parent")
|
||||
parent = await session.get(Category, parent_id)
|
||||
if parent is None:
|
||||
raise HTTPException(status_code=400, detail="Parent category not found")
|
||||
if parent.parent_id is not None:
|
||||
raise HTTPException(status_code=400, detail="Subcategories can only be one level deep")
|
||||
if parent.kind != kind:
|
||||
raise HTTPException(status_code=400, detail="A subcategory must have the same kind as its parent")
|
||||
|
||||
|
||||
@router.get("", response_model=list[CategoryRead])
|
||||
async def list_categories(
|
||||
kind: CategoryKind | None = None, session: AsyncSession = Depends(get_session)
|
||||
@@ -23,6 +38,8 @@ async def list_categories(
|
||||
|
||||
@router.post("", response_model=CategoryRead, status_code=201)
|
||||
async def create_category(payload: CategoryCreate, session: AsyncSession = Depends(get_session)) -> Category:
|
||||
if payload.parent_id is not None:
|
||||
await _validate_parent(session, payload.parent_id, payload.kind, category_id=None)
|
||||
category = Category(**payload.model_dump(), is_default=False)
|
||||
session.add(category)
|
||||
await session.commit()
|
||||
@@ -37,7 +54,15 @@ async def update_category(
|
||||
category = await session.get(Category, category_id)
|
||||
if category is None:
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
if "parent_id" in updates and updates["parent_id"] is not None:
|
||||
await _validate_parent(session, updates["parent_id"], category.kind, category_id=category_id)
|
||||
has_children = (
|
||||
await session.execute(select(Category.id).where(Category.parent_id == category_id).limit(1))
|
||||
).first()
|
||||
if has_children is not None:
|
||||
raise HTTPException(status_code=400, detail="A category with subcategories cannot become a subcategory itself")
|
||||
for field, value in updates.items():
|
||||
setattr(category, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(category)
|
||||
|
||||
Reference in New Issue
Block a user