mirror of
https://github.com/dat515-2025/Group-8.git
synced 2026-03-22 06:57:47 +01:00
feat(frontend): improved Dashboard.tsx, added transaction date
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
"""add date to transaction
|
||||
|
||||
Revision ID: 1f2a3c4d5e6f
|
||||
Revises: eabec90a94fe
|
||||
Create Date: 2025-10-22 16:18:00
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '1f2a3c4d5e6f'
|
||||
down_revision: Union[str, Sequence[str], None] = 'eabec90a94fe'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema by adding date column with server default current_date."""
|
||||
op.add_column(
|
||||
'transaction',
|
||||
sa.Column('date', sa.Date(), nullable=False, server_default=sa.text('CURRENT_DATE'))
|
||||
)
|
||||
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema by removing date column."""
|
||||
op.drop_column('transaction', 'date')
|
||||
@@ -5,7 +5,7 @@ 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.schemas.category import CategoryCreate, CategoryRead, CategoryUpdate
|
||||
from app.services.db import get_async_session
|
||||
from app.services.user_service import current_active_user
|
||||
from app.models.user import User
|
||||
@@ -43,6 +43,37 @@ async def list_categories(
|
||||
return list(res.scalars())
|
||||
|
||||
|
||||
@router.patch("/{category_id}", response_model=CategoryRead)
|
||||
async def update_category(
|
||||
category_id: int,
|
||||
payload: CategoryUpdate,
|
||||
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")
|
||||
|
||||
# If name changed, check uniqueness per user
|
||||
if payload.name is not None and payload.name != category.name:
|
||||
dup = await session.execute(
|
||||
select(Category.id).where(Category.user_id == user.id, Category.name == payload.name)
|
||||
)
|
||||
if dup.scalar_one_or_none() is not None:
|
||||
raise HTTPException(status_code=409, detail="Category with this name already exists")
|
||||
category.name = payload.name
|
||||
|
||||
if payload.description is not None:
|
||||
category.description = payload.description
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(category)
|
||||
return category
|
||||
|
||||
|
||||
@router.get("/{category_id}", response_model=CategoryRead)
|
||||
async def get_category(
|
||||
category_id: int,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from typing import List, Optional
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, and_, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.transaction import Transaction
|
||||
@@ -23,6 +24,7 @@ def _to_read_model(tx: Transaction) -> TransactionRead:
|
||||
id=tx.id,
|
||||
amount=tx.amount,
|
||||
description=tx.description,
|
||||
date=tx.date,
|
||||
category_ids=[c.id for c in (tx.categories or [])],
|
||||
)
|
||||
|
||||
@@ -33,7 +35,21 @@ async def create_transaction(
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
tx = Transaction(amount=payload.amount, description=payload.description, user_id=user.id)
|
||||
# Build transaction; set `date` only if provided to let DB default apply otherwise
|
||||
tx_kwargs = dict(
|
||||
amount=payload.amount,
|
||||
description=payload.description,
|
||||
user_id=user.id,
|
||||
)
|
||||
if payload.date is not None:
|
||||
parsed_date = payload.date
|
||||
if isinstance(parsed_date, str):
|
||||
try:
|
||||
parsed_date = date.fromisoformat(parsed_date)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format, expected YYYY-MM-DD")
|
||||
tx_kwargs["date"] = parsed_date
|
||||
tx = Transaction(**tx_kwargs)
|
||||
|
||||
# Attach categories if provided (and owned by user)
|
||||
if payload.category_ids:
|
||||
@@ -60,11 +76,18 @@ async def create_transaction(
|
||||
|
||||
@router.get("/", response_model=List[TransactionRead])
|
||||
async def list_transactions(
|
||||
start_date: Optional[date] = None,
|
||||
end_date: Optional[date] = None,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
cond = [Transaction.user_id == user.id]
|
||||
if start_date is not None:
|
||||
cond.append(Transaction.date >= start_date)
|
||||
if end_date is not None:
|
||||
cond.append(Transaction.date <= end_date)
|
||||
res = await session.execute(
|
||||
select(Transaction).where(Transaction.user_id == user.id).order_by(Transaction.id)
|
||||
select(Transaction).where(and_(*cond)).order_by(Transaction.date, Transaction.id)
|
||||
)
|
||||
txs = list(res.scalars())
|
||||
# Eagerly load categories for each transaction
|
||||
@@ -73,6 +96,36 @@ async def list_transactions(
|
||||
return [_to_read_model(tx) for tx in txs]
|
||||
|
||||
|
||||
@router.get("/balance_series")
|
||||
async def get_balance_series(
|
||||
start_date: Optional[date] = None,
|
||||
end_date: Optional[date] = None,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
cond = [Transaction.user_id == user.id]
|
||||
if start_date is not None:
|
||||
cond.append(Transaction.date >= start_date)
|
||||
if end_date is not None:
|
||||
cond.append(Transaction.date <= end_date)
|
||||
res = await session.execute(
|
||||
select(Transaction).where(and_(*cond)).order_by(Transaction.date, Transaction.id)
|
||||
)
|
||||
txs = list(res.scalars())
|
||||
# Group by date and accumulate
|
||||
daily = {}
|
||||
for tx in txs:
|
||||
key = tx.date.isoformat() if hasattr(tx.date, 'isoformat') else str(tx.date)
|
||||
daily[key] = daily.get(key, 0.0) + float(tx.amount)
|
||||
# Build cumulative series sorted by date
|
||||
series = []
|
||||
running = 0.0
|
||||
for d in sorted(daily.keys()):
|
||||
running += daily[d]
|
||||
series.append({"date": d, "balance": running})
|
||||
return series
|
||||
|
||||
|
||||
@router.get("/{transaction_id}", response_model=TransactionRead)
|
||||
async def get_transaction(
|
||||
transaction_id: int,
|
||||
@@ -111,6 +164,14 @@ async def update_transaction(
|
||||
tx.amount = payload.amount
|
||||
if payload.description is not None:
|
||||
tx.description = payload.description
|
||||
if payload.date is not None:
|
||||
new_date = payload.date
|
||||
if isinstance(new_date, str):
|
||||
try:
|
||||
new_date = date.fromisoformat(new_date)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format, expected YYYY-MM-DD")
|
||||
tx.date = new_date
|
||||
|
||||
if payload.category_ids is not None:
|
||||
# Preload categories to avoid async lazy-load during assignment
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from fastapi_users_db_sqlalchemy import GUID
|
||||
from sqlalchemy import Column, Integer, String, Float, ForeignKey
|
||||
from sqlalchemy import Column, Integer, String, Float, ForeignKey, Date, func
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.core.base import Base
|
||||
from app.models.categories import association_table
|
||||
@@ -10,6 +10,7 @@ class Transaction(Base):
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
amount = Column(Float, nullable=False)
|
||||
description = Column(String(length=255), nullable=True)
|
||||
date = Column(Date, nullable=False, server_default=func.current_date())
|
||||
user_id = Column(GUID, ForeignKey("user.id"), nullable=False)
|
||||
|
||||
# Relationship
|
||||
|
||||
@@ -11,6 +11,11 @@ class CategoryCreate(CategoryBase):
|
||||
pass
|
||||
|
||||
|
||||
class CategoryUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class CategoryRead(CategoryBase):
|
||||
id: int
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -1,10 +1,13 @@
|
||||
from typing import List, Optional
|
||||
from typing import List, Optional, Union
|
||||
from datetime import date
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
|
||||
class TransactionBase(BaseModel):
|
||||
amount: float = Field(..., gt=-1e18, lt=1e18)
|
||||
description: Optional[str] = None
|
||||
# accept either ISO date string or date object
|
||||
date: Optional[Union[date, str]] = None
|
||||
|
||||
class TransactionCreate(TransactionBase):
|
||||
category_ids: Optional[List[int]] = None
|
||||
@@ -12,10 +15,12 @@ class TransactionCreate(TransactionBase):
|
||||
class TransactionUpdate(BaseModel):
|
||||
amount: Optional[float] = Field(None, gt=-1e18, lt=1e18)
|
||||
description: Optional[str] = None
|
||||
# accept either ISO date string or date object
|
||||
date: Optional[Union[date, str]] = None
|
||||
category_ids: Optional[List[int]] = None
|
||||
|
||||
class TransactionRead(TransactionBase):
|
||||
id: int
|
||||
category_ids: List[int] = []
|
||||
|
||||
date: Optional[Union[date, str]]
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
Reference in New Issue
Block a user