-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1 from farting-lizards/add-crud-endpoints-for-exp…
…enses Add crud endpoints for expenses
- Loading branch information
Showing
40 changed files
with
1,093 additions
and
67 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,4 @@ | ||
### VisualStudioCode ### | ||
.vscode/* | ||
|
||
__pycache__ | ||
__pycache__ |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
File renamed without changes.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.