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..44490f3 --- /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("/create", 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..5fc361c --- /dev/null +++ b/7project/backend/app/api/transactions.py @@ -0,0 +1,219 @@ +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("/create", 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="Duplicate category IDs provided or 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}/edit", 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: + # 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(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}/delete", 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 423ab2c..61f829a 100644 --- a/7project/backend/app/app.py +++ b/7project/backend/app/app.py @@ -3,7 +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 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 from app.services.user_service import auth_backend, current_active_verified_user, fastapi_users, get_oauth_provider fastApi = FastAPI() @@ -20,29 +23,9 @@ fastApi.add_middleware( allow_headers=["*"], ) -fastApi.include_router( - fastapi_users.get_auth_router(auth_backend), prefix="/auth/jwt", tags=["auth"] -) -fastApi.include_router( - fastapi_users.get_register_router(UserRead, UserCreate), - prefix="/auth", - tags=["auth"], -) -fastApi.include_router( - fastapi_users.get_reset_password_router(), - prefix="/auth", - tags=["auth"], -) -fastApi.include_router( - fastapi_users.get_verify_router(UserRead), - prefix="/auth", - tags=["auth"], -) -fastApi.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) fastApi.include_router( fastapi_users.get_oauth_router( diff --git a/7project/backend/app/schemas/category.py b/7project/backend/app/schemas/category.py new file mode 100644 index 0000000..07fedaf --- /dev/null +++ b/7project/backend/app/schemas/category.py @@ -0,0 +1,16 @@ +from typing import Optional +from pydantic import BaseModel, ConfigDict + + +class CategoryBase(BaseModel): + name: str + description: Optional[str] = None + + +class CategoryCreate(CategoryBase): + pass + + +class CategoryRead(CategoryBase): + id: int + 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 new file mode 100644 index 0000000..9d82b4f --- /dev/null +++ b/7project/backend/app/schemas/transaction.py @@ -0,0 +1,21 @@ +from typing import List, Optional +from pydantic import BaseModel, Field, ConfigDict + + +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] = [] + + model_config = ConfigDict(from_attributes=True) 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