feat: initial commit
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import os
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
||||
|
||||
DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data")
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
|
||||
SQLALCHEMY_DATABASE_URL = f"sqlite:///{os.path.join(DATA_DIR, 'kopilka.db')}"
|
||||
|
||||
engine = create_engine(
|
||||
SQLALCHEMY_DATABASE_URL,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,24 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .database import engine, Base
|
||||
from .routers import piggy_banks
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
app = FastAPI(title="Kopilka API", version="1.0.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:5173", "http://localhost:3000"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(piggy_banks.router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,42 @@
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import Column, Integer, String, Float, ForeignKey, DateTime, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from .database import Base
|
||||
|
||||
|
||||
class Category(Base):
|
||||
__tablename__ = "categories"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, unique=True, nullable=False)
|
||||
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
piggy_banks = relationship("PiggyBank", back_populates="category")
|
||||
|
||||
|
||||
class PiggyBank(Base):
|
||||
__tablename__ = "piggy_banks"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
target_amount = Column(Float, nullable=False)
|
||||
current_amount = Column(Float, default=0.0, nullable=False)
|
||||
currency = Column(String, default="USD", nullable=False)
|
||||
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
|
||||
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
category = relationship("Category", back_populates="piggy_banks")
|
||||
transactions = relationship("Transaction", back_populates="piggy_bank", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class Transaction(Base):
|
||||
__tablename__ = "transactions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
piggy_bank_id = Column(Integer, ForeignKey("piggy_banks.id"), nullable=False)
|
||||
amount = Column(Float, nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
piggy_bank = relationship("PiggyBank", back_populates="transactions")
|
||||
@@ -0,0 +1,157 @@
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from .. import models, schemas
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["piggy-banks"])
|
||||
|
||||
|
||||
# --- Categories ---
|
||||
@router.get("/categories", response_model=List[schemas.CategoryResponse])
|
||||
def list_categories(db: Session = Depends(get_db)):
|
||||
return db.query(models.Category).order_by(models.Category.name).all()
|
||||
|
||||
|
||||
@router.post("/categories", response_model=schemas.CategoryResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_category(payload: schemas.CategoryCreate, db: Session = Depends(get_db)):
|
||||
existing = db.query(models.Category).filter(models.Category.name == payload.name).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Category already exists")
|
||||
category = models.Category(name=payload.name)
|
||||
db.add(category)
|
||||
db.commit()
|
||||
db.refresh(category)
|
||||
return category
|
||||
|
||||
|
||||
# --- Piggy Banks ---
|
||||
@router.get("/piggy-banks", response_model=List[schemas.PiggyBankResponse])
|
||||
def list_piggy_banks(db: Session = Depends(get_db)):
|
||||
banks = db.query(models.PiggyBank).order_by(models.PiggyBank.created_at.desc()).all()
|
||||
result = []
|
||||
for bank in banks:
|
||||
progress = round((bank.current_amount / bank.target_amount) * 100, 2) if bank.target_amount > 0 else 0
|
||||
result.append(schemas.PiggyBankResponse(
|
||||
id=bank.id,
|
||||
name=bank.name,
|
||||
target_amount=bank.target_amount,
|
||||
current_amount=bank.current_amount,
|
||||
currency=bank.currency,
|
||||
category_id=bank.category_id,
|
||||
progress_percent=progress,
|
||||
created_at=bank.created_at,
|
||||
updated_at=bank.updated_at,
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/piggy-banks/{bank_id}", response_model=schemas.PiggyBankResponse)
|
||||
def get_piggy_bank(bank_id: int, db: Session = Depends(get_db)):
|
||||
bank = db.query(models.PiggyBank).filter(models.PiggyBank.id == bank_id).first()
|
||||
if not bank:
|
||||
raise HTTPException(status_code=404, detail="Piggy bank not found")
|
||||
progress = round((bank.current_amount / bank.target_amount) * 100, 2) if bank.target_amount > 0 else 0
|
||||
return schemas.PiggyBankResponse(
|
||||
id=bank.id,
|
||||
name=bank.name,
|
||||
target_amount=bank.target_amount,
|
||||
current_amount=bank.current_amount,
|
||||
currency=bank.currency,
|
||||
category_id=bank.category_id,
|
||||
progress_percent=progress,
|
||||
created_at=bank.created_at,
|
||||
updated_at=bank.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/piggy-banks", response_model=schemas.PiggyBankResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_piggy_bank(payload: schemas.PiggyBankCreate, db: Session = Depends(get_db)):
|
||||
if payload.category_id:
|
||||
cat = db.query(models.Category).filter(models.Category.id == payload.category_id).first()
|
||||
if not cat:
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
bank = models.PiggyBank(**payload.model_dump())
|
||||
db.add(bank)
|
||||
db.commit()
|
||||
db.refresh(bank)
|
||||
progress = round((bank.current_amount / bank.target_amount) * 100, 2) if bank.target_amount > 0 else 0
|
||||
return schemas.PiggyBankResponse(
|
||||
id=bank.id,
|
||||
name=bank.name,
|
||||
target_amount=bank.target_amount,
|
||||
current_amount=bank.current_amount,
|
||||
currency=bank.currency,
|
||||
category_id=bank.category_id,
|
||||
progress_percent=progress,
|
||||
created_at=bank.created_at,
|
||||
updated_at=bank.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/piggy-banks/{bank_id}", response_model=schemas.PiggyBankResponse)
|
||||
def update_piggy_bank(bank_id: int, payload: schemas.PiggyBankUpdate, db: Session = Depends(get_db)):
|
||||
bank = db.query(models.PiggyBank).filter(models.PiggyBank.id == bank_id).first()
|
||||
if not bank:
|
||||
raise HTTPException(status_code=404, detail="Piggy bank not found")
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
if "category_id" in update_data and update_data["category_id"] is not None:
|
||||
cat = db.query(models.Category).filter(models.Category.id == update_data["category_id"]).first()
|
||||
if not cat:
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
for key, value in update_data.items():
|
||||
setattr(bank, key, value)
|
||||
db.commit()
|
||||
db.refresh(bank)
|
||||
progress = round((bank.current_amount / bank.target_amount) * 100, 2) if bank.target_amount > 0 else 0
|
||||
return schemas.PiggyBankResponse(
|
||||
id=bank.id,
|
||||
name=bank.name,
|
||||
target_amount=bank.target_amount,
|
||||
current_amount=bank.current_amount,
|
||||
currency=bank.currency,
|
||||
category_id=bank.category_id,
|
||||
progress_percent=progress,
|
||||
created_at=bank.created_at,
|
||||
updated_at=bank.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/piggy-banks/{bank_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_piggy_bank(bank_id: int, db: Session = Depends(get_db)):
|
||||
bank = db.query(models.PiggyBank).filter(models.PiggyBank.id == bank_id).first()
|
||||
if not bank:
|
||||
raise HTTPException(status_code=404, detail="Piggy bank not found")
|
||||
db.delete(bank)
|
||||
db.commit()
|
||||
|
||||
|
||||
# --- Transactions ---
|
||||
@router.post("/piggy-banks/{bank_id}/transactions", response_model=schemas.TransactionResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_transaction(bank_id: int, payload: schemas.TransactionCreate, db: Session = Depends(get_db)):
|
||||
bank = db.query(models.PiggyBank).filter(models.PiggyBank.id == bank_id).first()
|
||||
if not bank:
|
||||
raise HTTPException(status_code=404, detail="Piggy bank not found")
|
||||
if bank.current_amount + payload.amount < 0:
|
||||
raise HTTPException(status_code=400, detail="Insufficient funds")
|
||||
bank.current_amount += payload.amount
|
||||
transaction = models.Transaction(
|
||||
piggy_bank_id=bank_id,
|
||||
amount=payload.amount,
|
||||
description=payload.description,
|
||||
)
|
||||
db.add(transaction)
|
||||
db.commit()
|
||||
db.refresh(transaction)
|
||||
return transaction
|
||||
|
||||
|
||||
@router.get("/piggy-banks/{bank_id}/transactions", response_model=List[schemas.TransactionResponse])
|
||||
def list_transactions(bank_id: int, db: Session = Depends(get_db)):
|
||||
bank = db.query(models.PiggyBank).filter(models.PiggyBank.id == bank_id).first()
|
||||
if not bank:
|
||||
raise HTTPException(status_code=404, detail="Piggy bank not found")
|
||||
return db.query(models.Transaction).filter(
|
||||
models.Transaction.piggy_bank_id == bank_id
|
||||
).order_by(models.Transaction.created_at.desc()).all()
|
||||
@@ -0,0 +1,58 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# --- Category ---
|
||||
class CategoryBase(BaseModel):
|
||||
name: str
|
||||
|
||||
class CategoryCreate(CategoryBase):
|
||||
pass
|
||||
|
||||
class CategoryResponse(CategoryBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# --- PiggyBank ---
|
||||
class PiggyBankBase(BaseModel):
|
||||
name: str
|
||||
target_amount: float = Field(gt=0)
|
||||
currency: str = "USD"
|
||||
category_id: Optional[int] = None
|
||||
|
||||
class PiggyBankCreate(PiggyBankBase):
|
||||
pass
|
||||
|
||||
class PiggyBankUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
target_amount: Optional[float] = Field(default=None, gt=0)
|
||||
currency: Optional[str] = None
|
||||
category_id: Optional[int] = None
|
||||
|
||||
class PiggyBankResponse(PiggyBankBase):
|
||||
id: int
|
||||
current_amount: float
|
||||
progress_percent: float
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# --- Transaction ---
|
||||
class TransactionCreate(BaseModel):
|
||||
amount: float = Field(ne=0)
|
||||
description: Optional[str] = None
|
||||
|
||||
class TransactionResponse(BaseModel):
|
||||
id: int
|
||||
piggy_bank_id: int
|
||||
amount: float
|
||||
description: Optional[str]
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
Reference in New Issue
Block a user