Skip to content

Commit

Permalink
Merge pull request #1 from farting-lizards/add-crud-endpoints-for-exp…
Browse files Browse the repository at this point in the history
…enses

Add crud endpoints for expenses
  • Loading branch information
Dinika authored Apr 28, 2024
2 parents d1cb9e4 + f5c4ed1 commit 74268d6
Show file tree
Hide file tree
Showing 40 changed files with 1,093 additions and 67 deletions.
14 changes: 14 additions & 0 deletions .github/workflows/pre-commit.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
name: pre-commit

on:
pull_request:
push:
branches: [main]

jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v3
- uses: pre-commit/[email protected]
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
### VisualStudioCode ###
.vscode/*

__pycache__
__pycache__
23 changes: 23 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: check-yaml
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.1
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: "v1.9.0"
hooks:
- id: mypy
args: [--strict]
additional_dependencies:
- "pydantic"
- "fastapi"
- "sqlalchemy"
- "pytest"
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Expenses Server in FastAPI

# Development

1. Start the db:
```sh
sudo docker compose -f docker-compose.yml -p expenses_server up
```

2. Start the server on port 8090:
```sh
poetry run uvicorn expenses_server.main:app --reload --port=8090 --use-colors
```

3. Running tests

```sh
poetry run pytest # runs all tests
poetry run pytest -s --pdb # helpful for debugging tests
```

4. Kill the containers and remove volumes

```sh
sudo docker compose -f docker-compose.yml -p expenses_server down --remove-orphans --volumes
```
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ volumes:

services:
expenses_db:
image: postgres:latest
image: docker.io/postgres:latest
container_name: expenses_db
environment:
POSTGRES_USER: expenses
Expand Down
File renamed without changes.
21 changes: 21 additions & 0 deletions expenses_server/db.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from typing import Generator
from pydantic_core import MultiHostUrl
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session
from pydantic import PostgresDsn

DB_URL: PostgresDsn = MultiHostUrl(
"postgresql://expenses:expenses@localhost:15000/expenses"
)

engine = create_engine(DB_URL.unicode_string(), echo=True)

SessionLocal = sessionmaker(autoflush=False, autocommit=False, bind=engine)


def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()
File renamed without changes.
17 changes: 17 additions & 0 deletions expenses_server/db_models/account.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from typing import TYPE_CHECKING
from uuid import uuid4
from sqlalchemy import Integer, String
from sqlalchemy.orm import MappedColumn as Column, Mapped
from .core import Base
from sqlalchemy.orm import relationship


if TYPE_CHECKING:
from .expense import Expense


class Account(Base):
__tablename__ = "accounts"
name: Mapped[str] = Column(String, unique=True)
expenses: Mapped["Expense"] = relationship("Expense", back_populates="account")
id: Mapped[int] = Column(Integer, primary_key=True, default=uuid4)
10 changes: 10 additions & 0 deletions expenses_server/db_models/category.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from sqlalchemy.orm import mapped_column, Mapped
from .core import Base
from sqlalchemy.orm import relationship


class Category(Base):
__tablename__ = "categories"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(unique=True)
expenses = relationship("Expense", back_populates="category")
5 changes: 5 additions & 0 deletions expenses_server/db_models/core.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase, MappedAsDataclass


class Base(MappedAsDataclass, DeclarativeBase):
pass
39 changes: 39 additions & 0 deletions expenses_server/db_models/expense.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from uuid import uuid4, UUID
from sqlalchemy import TIMESTAMP, ForeignKey, Text, func, types
from sqlalchemy.orm import relationship, Mapped, mapped_column
from .core import Base
from typing import TYPE_CHECKING


if TYPE_CHECKING:
from .category import Category
from .account import Account


class Expense(Base):
__tablename__ = "expenses"

# TODO: Restrict type of currency
currency: Mapped[str]
amount: Mapped[float] = mapped_column()
description: Mapped[str | None] = mapped_column(Text)
category_id: Mapped[int] = mapped_column(ForeignKey("categories.id"))
category: Mapped["Category"] = relationship(
"Category", back_populates="expenses", init=False
)

# TODO: Check if back_populates and corresponding value in account is needed
account_id: Mapped[int] = mapped_column(ForeignKey("accounts.id"))
account: Mapped["Account"] = relationship(
"Account", back_populates="expenses", init=False
)

external_id: Mapped[str | None] = mapped_column(unique=True, default=None)
id: Mapped[UUID] = mapped_column(
types.UUID,
default_factory=uuid4,
primary_key=True,
)
timestamp: Mapped[str] = mapped_column(
TIMESTAMP, nullable=False, default=func.now()
)
File renamed without changes.
9 changes: 9 additions & 0 deletions expenses_server/dtos/accounts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from pydantic import BaseModel


class Account(BaseModel):
id: int
name: str

class Config:
from_attributes = True
9 changes: 9 additions & 0 deletions expenses_server/dtos/categories.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from pydantic import BaseModel


class Category(BaseModel):
id: int
name: str

class Config:
from_attributes = True
6 changes: 6 additions & 0 deletions expenses_server/dtos/currencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from enum import Enum


class CurrencyEnum(Enum):
CHF = "CHF"
EUR = "EUR"
43 changes: 43 additions & 0 deletions expenses_server/dtos/expenses.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from datetime import datetime
from pydantic import UUID4, BaseModel, field_serializer
from .categories import Category
from .accounts import Account
from .currencies import CurrencyEnum


class ExpenseBase(BaseModel):
amount: float
currency: CurrencyEnum # TODO: In OpenApI this does not show option "EUR"
description: str | None
category_name: str = "other"
account_id: int


class ExpenseCreate(ExpenseBase):
class Config:
from_attributes = True


class ExpenseUpdate(BaseModel):
amount: float | None = None
currency: CurrencyEnum | None = None
description: str | None = None
category_name: str | None = None
account_id: int | None = None


class ExpenseDTO(BaseModel):
id: UUID4
timestamp: datetime
account: Account
amount: float
currency: CurrencyEnum
description: str | None
category: Category

@field_serializer("category")
def serialize_category(self, category: Category) -> str:
return category.name

class Config:
from_attributes = True
31 changes: 31 additions & 0 deletions expenses_server/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from fastapi import APIRouter, FastAPI
from .db import engine
from .db_models.core import Base
from .routes.accounts import router as account_router
from .routes.expenses import router as expense_router
from sqlalchemy import event
from .db_models.account import Account
from .db_models.category import Category
from .seed_db import populate_accounts, populate_categories

app = FastAPI()
base_router = APIRouter(prefix="/api")


event.listen(Category.__table__, "after_create", populate_categories)
event.listen(Account.__table__, "after_create", populate_accounts)

Base.metadata.create_all(bind=engine)


base_router.get("/")


async def root() -> dict[str, str]:
return {"message": "Hello World"}


base_router.include_router(account_router)
base_router.include_router(expense_router)

app.include_router(base_router)
File renamed without changes.
14 changes: 14 additions & 0 deletions expenses_server/routes/accounts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from ..db import get_db
from ..db_models.account import Account as DBAccount
from ..dtos.accounts import Account


router = APIRouter(prefix="/accounts")


@router.get("", response_model=list[Account])
def get_all_accounts(db: Session = Depends(get_db)) -> list[DBAccount]:
# TODO: Only accounts for user should be sent
return db.query(DBAccount).all()
102 changes: 102 additions & 0 deletions expenses_server/routes/expenses.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
from http import HTTPStatus
from typing import cast
from fastapi import APIRouter, Depends, HTTPException
from pydantic import UUID4

from expenses_server.dtos.currencies import CurrencyEnum

from ..db import get_db
from ..db_models.category import Category
from ..dtos.expenses import ExpenseDTO, ExpenseCreate, ExpenseUpdate
from ..db_models.expense import Expense as DBExpense
from sqlalchemy.orm import Session


router = APIRouter(prefix="/expenses")


@router.post("", response_model=ExpenseDTO)
async def create_expense(
expense: ExpenseCreate, session: Session = Depends(get_db)
) -> DBExpense:
db_category = (
session.query(Category).filter(Category.name == expense.category_name).first()
)
db_expense = DBExpense(
amount=expense.amount,
description=expense.description,
currency=expense.currency.value,
# TODO Default category should be handled better
# TODO Should the default not be set for category?
category_id=db_category.id if db_category is not None else 13,
account_id=expense.account_id,
)
session.add(db_expense)
session.commit()
session.refresh(db_expense)
return db_expense


@router.get("", response_model=list[ExpenseDTO])
async def get_all_expenses(session: Session = Depends(get_db)) -> list[ExpenseDTO]:
db_expenses = session.query(DBExpense).order_by(DBExpense.timestamp.desc()).all()
expenses = [ExpenseDTO.model_validate(expense) for expense in db_expenses]
return expenses


@router.get("/{expense_id}", response_model=ExpenseDTO)
async def get_expense(
expense_id: UUID4, session: Session = Depends(get_db)
) -> ExpenseDTO:
db_expense = session.get(DBExpense, expense_id)
if db_expense is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Expense with id {expense_id} not found",
)
return ExpenseDTO.model_validate(db_expense)


@router.patch("/{expense_id}", response_model=ExpenseDTO)
async def update_expense(
expense_id: UUID4, expense_update: ExpenseUpdate, session: Session = Depends(get_db)
) -> ExpenseDTO:
# TODO: Ask David - How to best refactor this
payload = expense_update.model_dump(exclude_none=True)

# Replace category_name with category_id
if payload.get("category_name") is not None:
db_category = (
session.query(Category)
.filter(Category.name == payload.get("category_name", "other"))
.first()
)
del payload["category_name"]
payload["category_id"] = db_category.id if db_category is not None else 13
# Replace currency name with currency
if payload.get("currency"):
payload["currency"] = cast(CurrencyEnum, payload["currency"]).value

session.query(DBExpense).filter(DBExpense.id == expense_id).update(
values=payload # type: ignore
)
session.commit()
updated_expense = session.get(DBExpense, expense_id)
return ExpenseDTO.model_validate(updated_expense)


@router.delete("/{expense_id}", response_model=ExpenseDTO)
async def delete_expenses(
expense_id: UUID4, db: Session = Depends(get_db)
) -> ExpenseDTO:
db_expense = db.get(DBExpense, expense_id)
if db_expense is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Expense with id {expense_id} not found",
)

expense = ExpenseDTO.model_validate(db_expense)
db.delete(db_expense)
db.commit()
return expense
Loading

0 comments on commit 74268d6

Please sign in to comment.