From 6c248039ac39a7be9df64e6a3f71192e81a36e85 Mon Sep 17 00:00:00 2001 From: ribardej Date: Fri, 10 Oct 2025 16:16:43 +0200 Subject: [PATCH 1/6] feat(backend): fixed DB user schema --- 7project/backend/app/schemas/user.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/7project/backend/app/schemas/user.py b/7project/backend/app/schemas/user.py index b5ae272..bb4bd4f 100644 --- a/7project/backend/app/schemas/user.py +++ b/7project/backend/app/schemas/user.py @@ -4,13 +4,13 @@ from fastapi_users import schemas class UserRead(schemas.BaseUser[uuid.UUID]): first_name: Optional[str] = None - surname: Optional[str] = None + last_name: Optional[str] = None class UserCreate(schemas.BaseUserCreate): first_name: Optional[str] = None - surname: Optional[str] = None + last_name: Optional[str] = None class UserUpdate(schemas.BaseUserUpdate): first_name: Optional[str] = None - surname: Optional[str] = None + last_name: Optional[str] = None From 2f20fb12e4c5c34c31f1d859b46f93f09da8dc7f Mon Sep 17 00:00:00 2001 From: ribardej Date: Mon, 13 Oct 2025 12:07:47 +0200 Subject: [PATCH 2/6] feat(backend): implemented basic controller layer --- 7project/backend/app/api/auth.py | 31 +++ 7project/backend/app/api/categories.py | 77 +++++++ 7project/backend/app/api/transactions.py | 213 ++++++++++++++++++++ 7project/backend/app/app.py | 32 +-- 7project/backend/app/schemas/category.py | 23 +++ 7project/backend/app/schemas/transaction.py | 21 ++ 6 files changed, 372 insertions(+), 25 deletions(-) create mode 100644 7project/backend/app/api/auth.py create mode 100644 7project/backend/app/api/categories.py create mode 100644 7project/backend/app/api/transactions.py create mode 100644 7project/backend/app/schemas/category.py create mode 100644 7project/backend/app/schemas/transaction.py diff --git a/7project/backend/app/api/auth.py b/7project/backend/app/api/auth.py new file mode 100644 index 0000000..576e80e --- /dev/null +++ b/7project/backend/app/api/auth.py @@ -0,0 +1,31 @@ +from fastapi import APIRouter + +from app.schemas.user import UserCreate, UserRead, UserUpdate +from app.services.user_service import auth_backend, fastapi_users + +router = APIRouter() + +# Keep existing paths as-is under /auth/* and /users/* +router.include_router( + fastapi_users.get_auth_router(auth_backend), prefix="/auth/jwt", tags=["auth"] +) +router.include_router( + fastapi_users.get_register_router(UserRead, UserCreate), + prefix="/auth", + tags=["auth"], +) +router.include_router( + fastapi_users.get_reset_password_router(), + prefix="/auth", + tags=["auth"], +) +router.include_router( + fastapi_users.get_verify_router(UserRead), + prefix="/auth", + tags=["auth"], +) +router.include_router( + fastapi_users.get_users_router(UserRead, UserUpdate), + prefix="/users", + tags=["users"], +) diff --git a/7project/backend/app/api/categories.py b/7project/backend/app/api/categories.py new file mode 100644 index 0000000..b1c3bf7 --- /dev/null +++ b/7project/backend/app/api/categories.py @@ -0,0 +1,77 @@ +from typing import List + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select, delete +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.categories import Category +from app.schemas.category import CategoryCreate, CategoryRead +from app.services.db import get_async_session +from app.services.user_service import current_active_user +from app.models.user import User + +router = APIRouter(prefix="/categories", tags=["categories"]) + + +@router.post("/", response_model=CategoryRead, status_code=status.HTTP_201_CREATED) +async def create_category( + payload: CategoryCreate, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + # Enforce per-user unique name via query to provide 409 feedback + res = await session.execute( + select(Category).where(Category.user_id == user.id, Category.name == payload.name) + ) + existing = res.scalar_one_or_none() + if existing: + raise HTTPException(status_code=409, detail="Category with this name already exists") + + category = Category(name=payload.name, description=payload.description, user_id=user.id) + session.add(category) + await session.commit() + await session.refresh(category) + return category + + +@router.get("/", response_model=List[CategoryRead]) +async def list_categories( + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + res = await session.execute(select(Category).where(Category.user_id == user.id)) + return list(res.scalars()) + + +@router.get("/{category_id}", response_model=CategoryRead) +async def get_category( + category_id: int, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + res = await session.execute( + select(Category).where(Category.id == category_id, Category.user_id == user.id) + ) + category = res.scalar_one_or_none() + if not category: + raise HTTPException(status_code=404, detail="Category not found") + return category + + +@router.delete("/{category_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_category( + category_id: int, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + res = await session.execute( + select(Category.id).where(Category.id == category_id, Category.user_id == user.id) + ) + if res.scalar_one_or_none() is None: + raise HTTPException(status_code=404, detail="Category not found") + + await session.execute( + delete(Category).where(Category.id == category_id, Category.user_id == user.id) + ) + await session.commit() + return None diff --git a/7project/backend/app/api/transactions.py b/7project/backend/app/api/transactions.py new file mode 100644 index 0000000..967fdd1 --- /dev/null +++ b/7project/backend/app/api/transactions.py @@ -0,0 +1,213 @@ +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.transaction import Transaction +from app.models.categories import Category +from app.schemas.transaction import ( + TransactionCreate, + TransactionRead, + TransactionUpdate, +) +from app.services.db import get_async_session +from app.services.user_service import current_active_user +from app.models.user import User + +router = APIRouter(prefix="/transactions", tags=["transactions"]) + + +def _to_read_model(tx: Transaction) -> TransactionRead: + return TransactionRead( + id=tx.id, + amount=tx.amount, + description=tx.description, + category_ids=[c.id for c in (tx.categories or [])], + ) + + +@router.post("/", response_model=TransactionRead, status_code=status.HTTP_201_CREATED) +async def create_transaction( + payload: TransactionCreate, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + tx = Transaction(amount=payload.amount, description=payload.description, user_id=user.id) + + # Attach categories if provided (and owned by user) + if payload.category_ids: + res = await session.execute( + select(Category).where( + Category.user_id == user.id, Category.id.in_(payload.category_ids) + ) + ) + categories = list(res.scalars()) + if len(categories) != len(set(payload.category_ids)): + raise HTTPException(status_code=400, detail="One or more categories not found") + tx.categories = categories + + session.add(tx) + await session.commit() + await session.refresh(tx) + # Ensure categories are loaded + await session.refresh(tx, attribute_names=["categories"]) + return _to_read_model(tx) + + +@router.get("/", response_model=List[TransactionRead]) +async def list_transactions( + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + res = await session.execute( + select(Transaction).where(Transaction.user_id == user.id).order_by(Transaction.id) + ) + txs = list(res.scalars()) + # Eagerly load categories for each transaction + for tx in txs: + await session.refresh(tx, attribute_names=["categories"]) + return [_to_read_model(tx) for tx in txs] + + +@router.get("/{transaction_id}", response_model=TransactionRead) +async def get_transaction( + transaction_id: int, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + res = await session.execute( + select(Transaction).where( + Transaction.id == transaction_id, Transaction.user_id == user.id + ) + ) + tx: Optional[Transaction] = res.scalar_one_or_none() + if not tx: + raise HTTPException(status_code=404, detail="Transaction not found") + await session.refresh(tx, attribute_names=["categories"]) + return _to_read_model(tx) + + +@router.patch("/{transaction_id}", response_model=TransactionRead) +async def update_transaction( + transaction_id: int, + payload: TransactionUpdate, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + res = await session.execute( + select(Transaction).where( + Transaction.id == transaction_id, Transaction.user_id == user.id + ) + ) + tx: Optional[Transaction] = res.scalar_one_or_none() + if not tx: + raise HTTPException(status_code=404, detail="Transaction not found") + + if payload.amount is not None: + tx.amount = payload.amount + if payload.description is not None: + tx.description = payload.description + + if payload.category_ids is not None: + # Preload categories to avoid async lazy-load during assignment + await session.refresh(tx, attribute_names=["categories"]) + if payload.category_ids: + res = await session.execute( + select(Category).where( + Category.user_id == user.id, Category.id.in_(payload.category_ids) + ) + ) + categories = list(res.scalars()) + if len(categories) != len(set(payload.category_ids)): + raise HTTPException(status_code=400, detail="One or more categories not found") + tx.categories = categories + else: + tx.categories = [] + + await session.commit() + await session.refresh(tx, attribute_names=["categories"]) + return _to_read_model(tx) + + +@router.delete("/{transaction_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_transaction( + transaction_id: int, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + res = await session.execute( + select(Transaction).where( + Transaction.id == transaction_id, Transaction.user_id == user.id + ) + ) + tx = res.scalar_one_or_none() + if not tx: + raise HTTPException(status_code=404, detail="Transaction not found") + + await session.delete(tx) + await session.commit() + return None + + +@router.post("/{transaction_id}/categories/{category_id}", response_model=TransactionRead) +async def assign_category( + transaction_id: int, + category_id: int, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + # Load transaction and category ensuring ownership + res_tx = await session.execute( + select(Transaction).where( + Transaction.id == transaction_id, Transaction.user_id == user.id + ) + ) + tx: Optional[Transaction] = res_tx.scalar_one_or_none() + if not tx: + raise HTTPException(status_code=404, detail="Transaction not found") + + res_cat = await session.execute( + select(Category).where(Category.id == category_id, Category.user_id == user.id) + ) + cat: Optional[Category] = res_cat.scalar_one_or_none() + if not cat: + raise HTTPException(status_code=404, detail="Category not found") + + await session.refresh(tx, attribute_names=["categories"]) + if cat not in tx.categories: + tx.categories.append(cat) + await session.commit() + await session.refresh(tx, attribute_names=["categories"]) + return _to_read_model(tx) + + +@router.delete("/{transaction_id}/categories/{category_id}", response_model=TransactionRead) +async def unassign_category( + transaction_id: int, + category_id: int, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + res_tx = await session.execute( + select(Transaction).where( + Transaction.id == transaction_id, Transaction.user_id == user.id + ) + ) + tx: Optional[Transaction] = res_tx.scalar_one_or_none() + if not tx: + raise HTTPException(status_code=404, detail="Transaction not found") + + res_cat = await session.execute( + select(Category).where(Category.id == category_id, Category.user_id == user.id) + ) + cat: Optional[Category] = res_cat.scalar_one_or_none() + if not cat: + raise HTTPException(status_code=404, detail="Category not found") + + await session.refresh(tx, attribute_names=["categories"]) + if cat in tx.categories: + tx.categories.remove(cat) + await session.commit() + await session.refresh(tx, attribute_names=["categories"]) + return _to_read_model(tx) diff --git a/7project/backend/app/app.py b/7project/backend/app/app.py index 6349483..62fb7b3 100644 --- a/7project/backend/app/app.py +++ b/7project/backend/app/app.py @@ -3,8 +3,10 @@ from fastapi.middleware.cors import CORSMiddleware from app.models.user import User -from app.schemas.user import UserCreate, UserRead, UserUpdate -from app.services.user_service import auth_backend, current_active_verified_user, fastapi_users +from app.services.user_service import current_active_verified_user +from app.api.auth import router as auth_router +from app.api.categories import router as categories_router +from app.api.transactions import router as transactions_router app = FastAPI() @@ -20,29 +22,9 @@ app.add_middleware( allow_headers=["*"], ) -app.include_router( - fastapi_users.get_auth_router(auth_backend), prefix="/auth/jwt", tags=["auth"] -) -app.include_router( - fastapi_users.get_register_router(UserRead, UserCreate), - prefix="/auth", - tags=["auth"], -) -app.include_router( - fastapi_users.get_reset_password_router(), - prefix="/auth", - tags=["auth"], -) -app.include_router( - fastapi_users.get_verify_router(UserRead), - prefix="/auth", - tags=["auth"], -) -app.include_router( - fastapi_users.get_users_router(UserRead, UserUpdate), - prefix="/users", - tags=["users"], -) +app.include_router(auth_router) +app.include_router(categories_router) +app.include_router(transactions_router) # Liveness/root endpoint diff --git a/7project/backend/app/schemas/category.py b/7project/backend/app/schemas/category.py new file mode 100644 index 0000000..f2df310 --- /dev/null +++ b/7project/backend/app/schemas/category.py @@ -0,0 +1,23 @@ +from typing import Optional +from pydantic import BaseModel +try: + # Pydantic v2 + from pydantic import ConfigDict # type: ignore + _HAS_V2 = True +except Exception: # pragma: no cover + _HAS_V2 = False + +class CategoryBase(BaseModel): + name: str + description: Optional[str] = None + +class CategoryCreate(CategoryBase): + pass + +class CategoryRead(CategoryBase): + id: int + if _HAS_V2: + model_config = ConfigDict(from_attributes=True) # type: ignore + else: # Pydantic v1 fallback + class Config: # type: ignore + orm_mode = True diff --git a/7project/backend/app/schemas/transaction.py b/7project/backend/app/schemas/transaction.py new file mode 100644 index 0000000..2275dc0 --- /dev/null +++ b/7project/backend/app/schemas/transaction.py @@ -0,0 +1,21 @@ +from typing import List, Optional +from pydantic import BaseModel, Field + +class TransactionBase(BaseModel): + amount: float = Field(..., gt=-1e18, lt=1e18) + description: Optional[str] = None + +class TransactionCreate(TransactionBase): + category_ids: Optional[List[int]] = None + +class TransactionUpdate(BaseModel): + amount: Optional[float] = Field(None, gt=-1e18, lt=1e18) + description: Optional[str] = None + category_ids: Optional[List[int]] = None + +class TransactionRead(TransactionBase): + id: int + category_ids: List[int] = [] + + class Config: + from_attributes = True From f1065bc2749c0fd82836254dc4fb6df321f82629 Mon Sep 17 00:00:00 2001 From: ribardej Date: Mon, 13 Oct 2025 13:50:59 +0200 Subject: [PATCH 3/6] feat(backend): update consistent Pydantic v2 use everywhere --- 7project/backend/app/schemas/category.py | 17 +++++------------ 7project/backend/app/schemas/transaction.py | 6 +++--- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/7project/backend/app/schemas/category.py b/7project/backend/app/schemas/category.py index f2df310..07fedaf 100644 --- a/7project/backend/app/schemas/category.py +++ b/7project/backend/app/schemas/category.py @@ -1,23 +1,16 @@ from typing import Optional -from pydantic import BaseModel -try: - # Pydantic v2 - from pydantic import ConfigDict # type: ignore - _HAS_V2 = True -except Exception: # pragma: no cover - _HAS_V2 = False +from pydantic import BaseModel, ConfigDict + class CategoryBase(BaseModel): name: str description: Optional[str] = None + class CategoryCreate(CategoryBase): pass + class CategoryRead(CategoryBase): id: int - if _HAS_V2: - model_config = ConfigDict(from_attributes=True) # type: ignore - else: # Pydantic v1 fallback - class Config: # type: ignore - orm_mode = True + model_config = ConfigDict(from_attributes=True) \ No newline at end of file diff --git a/7project/backend/app/schemas/transaction.py b/7project/backend/app/schemas/transaction.py index 2275dc0..9d82b4f 100644 --- a/7project/backend/app/schemas/transaction.py +++ b/7project/backend/app/schemas/transaction.py @@ -1,5 +1,6 @@ from typing import List, Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, ConfigDict + class TransactionBase(BaseModel): amount: float = Field(..., gt=-1e18, lt=1e18) @@ -17,5 +18,4 @@ class TransactionRead(TransactionBase): id: int category_ids: List[int] = [] - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) From 975f5e5becbfc7c7e594e20e03381925eb44f6a9 Mon Sep 17 00:00:00 2001 From: Dejan Ribarovski Date: Mon, 13 Oct 2025 13:52:24 +0200 Subject: [PATCH 4/6] Update 7project/backend/app/api/transactions.py Better error message Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- 7project/backend/app/api/transactions.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/7project/backend/app/api/transactions.py b/7project/backend/app/api/transactions.py index 967fdd1..cd8fe04 100644 --- a/7project/backend/app/api/transactions.py +++ b/7project/backend/app/api/transactions.py @@ -44,7 +44,10 @@ async def create_transaction( ) categories = list(res.scalars()) if len(categories) != len(set(payload.category_ids)): - raise HTTPException(status_code=400, detail="One or more categories not found") + raise HTTPException( + status_code=400, + detail="Duplicate category IDs provided or one or more categories not found" + ) tx.categories = categories session.add(tx) From 9580bea63023cebcdf2b29cba3dc99d5cda00b89 Mon Sep 17 00:00:00 2001 From: Dejan Ribarovski Date: Mon, 13 Oct 2025 13:52:36 +0200 Subject: [PATCH 5/6] Update 7project/backend/app/api/transactions.py Better error message Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- 7project/backend/app/api/transactions.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/7project/backend/app/api/transactions.py b/7project/backend/app/api/transactions.py index cd8fe04..7308ecf 100644 --- a/7project/backend/app/api/transactions.py +++ b/7project/backend/app/api/transactions.py @@ -116,13 +116,16 @@ async def update_transaction( # Preload categories to avoid async lazy-load during assignment await session.refresh(tx, attribute_names=["categories"]) if payload.category_ids: + # Check for duplicate category IDs in the payload + if len(payload.category_ids) != len(set(payload.category_ids)): + raise HTTPException(status_code=400, detail="Duplicate category IDs in payload") res = await session.execute( select(Category).where( Category.user_id == user.id, Category.id.in_(payload.category_ids) ) ) categories = list(res.scalars()) - if len(categories) != len(set(payload.category_ids)): + if len(categories) != len(payload.category_ids): raise HTTPException(status_code=400, detail="One or more categories not found") tx.categories = categories else: From 30068079c69e88d2ea17da293a6caf8af52cf4d5 Mon Sep 17 00:00:00 2001 From: ribardej Date: Mon, 13 Oct 2025 13:56:44 +0200 Subject: [PATCH 6/6] feat(backend): renamed endpoints for consistency --- 7project/backend/app/api/categories.py | 2 +- 7project/backend/app/api/transactions.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/7project/backend/app/api/categories.py b/7project/backend/app/api/categories.py index b1c3bf7..44490f3 100644 --- a/7project/backend/app/api/categories.py +++ b/7project/backend/app/api/categories.py @@ -13,7 +13,7 @@ from app.models.user import User router = APIRouter(prefix="/categories", tags=["categories"]) -@router.post("/", response_model=CategoryRead, status_code=status.HTTP_201_CREATED) +@router.post("/create", response_model=CategoryRead, status_code=status.HTTP_201_CREATED) async def create_category( payload: CategoryCreate, session: AsyncSession = Depends(get_async_session), diff --git a/7project/backend/app/api/transactions.py b/7project/backend/app/api/transactions.py index 967fdd1..8a7215d 100644 --- a/7project/backend/app/api/transactions.py +++ b/7project/backend/app/api/transactions.py @@ -27,7 +27,7 @@ def _to_read_model(tx: Transaction) -> TransactionRead: ) -@router.post("/", response_model=TransactionRead, status_code=status.HTTP_201_CREATED) +@router.post("/create", response_model=TransactionRead, status_code=status.HTTP_201_CREATED) async def create_transaction( payload: TransactionCreate, session: AsyncSession = Depends(get_async_session), @@ -88,7 +88,7 @@ async def get_transaction( return _to_read_model(tx) -@router.patch("/{transaction_id}", response_model=TransactionRead) +@router.patch("/{transaction_id}/edit", response_model=TransactionRead) async def update_transaction( transaction_id: int, payload: TransactionUpdate, @@ -130,7 +130,7 @@ async def update_transaction( return _to_read_model(tx) -@router.delete("/{transaction_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete("/{transaction_id}/delete", status_code=status.HTTP_204_NO_CONTENT) async def delete_transaction( transaction_id: int, session: AsyncSession = Depends(get_async_session),