Some checks failed
Run Tests / test (push) Failing after 5m2s
- Introduced a new template for currency overview and management (`currencies.html`). - Updated footer to include attribution to AllYouCanGET. - Added "Currencies" link to the main navigation header. - Implemented end-to-end tests for currency creation, update, and activation toggling. - Created unit tests for currency API endpoints, including creation, updating, and activation toggling. - Added a fixture to seed default currencies for testing. - Enhanced database setup tests to ensure proper seeding and migration handling.
192 lines
5.4 KiB
Python
192 lines
5.4 KiB
Python
from typing import Dict, List, Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
from models.currency import Currency
|
|
from routes.dependencies import get_db
|
|
|
|
router = APIRouter(prefix="/api/currencies", tags=["Currencies"])
|
|
|
|
|
|
DEFAULT_CURRENCY_CODE = "USD"
|
|
DEFAULT_CURRENCY_NAME = "US Dollar"
|
|
DEFAULT_CURRENCY_SYMBOL = "$"
|
|
|
|
|
|
class CurrencyBase(BaseModel):
|
|
name: str = Field(..., min_length=1, max_length=128)
|
|
symbol: Optional[str] = Field(default=None, max_length=8)
|
|
|
|
@staticmethod
|
|
def _normalize_symbol(value: Optional[str]) -> Optional[str]:
|
|
if value is None:
|
|
return None
|
|
value = value.strip()
|
|
return value or None
|
|
|
|
@field_validator("name")
|
|
@classmethod
|
|
def _strip_name(cls, value: str) -> str:
|
|
return value.strip()
|
|
|
|
@field_validator("symbol")
|
|
@classmethod
|
|
def _strip_symbol(cls, value: Optional[str]) -> Optional[str]:
|
|
return cls._normalize_symbol(value)
|
|
|
|
|
|
class CurrencyCreate(CurrencyBase):
|
|
code: str = Field(..., min_length=3, max_length=3)
|
|
is_active: bool = True
|
|
|
|
@field_validator("code")
|
|
@classmethod
|
|
def _normalize_code(cls, value: str) -> str:
|
|
return value.strip().upper()
|
|
|
|
|
|
class CurrencyUpdate(CurrencyBase):
|
|
is_active: Optional[bool] = None
|
|
|
|
|
|
class CurrencyActivation(BaseModel):
|
|
is_active: bool
|
|
|
|
|
|
class CurrencyRead(CurrencyBase):
|
|
id: int
|
|
code: str
|
|
is_active: bool
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
|
|
def _ensure_default_currency(db: Session) -> Currency:
|
|
existing = (
|
|
db.query(Currency)
|
|
.filter(Currency.code == DEFAULT_CURRENCY_CODE)
|
|
.one_or_none()
|
|
)
|
|
if existing:
|
|
return existing
|
|
|
|
default_currency = Currency(
|
|
code=DEFAULT_CURRENCY_CODE,
|
|
name=DEFAULT_CURRENCY_NAME,
|
|
symbol=DEFAULT_CURRENCY_SYMBOL,
|
|
is_active=True,
|
|
)
|
|
db.add(default_currency)
|
|
try:
|
|
db.commit()
|
|
except IntegrityError:
|
|
db.rollback()
|
|
existing = (
|
|
db.query(Currency)
|
|
.filter(Currency.code == DEFAULT_CURRENCY_CODE)
|
|
.one()
|
|
)
|
|
return existing
|
|
db.refresh(default_currency)
|
|
return default_currency
|
|
|
|
|
|
def _get_currency_or_404(db: Session, code: str) -> Currency:
|
|
normalized = code.strip().upper()
|
|
currency = (
|
|
db.query(Currency)
|
|
.filter(Currency.code == normalized)
|
|
.one_or_none()
|
|
)
|
|
if currency is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Currency not found")
|
|
return currency
|
|
|
|
|
|
@router.get("/", response_model=List[CurrencyRead])
|
|
def list_currencies(
|
|
include_inactive: bool = Query(
|
|
False, description="Include inactive currencies"),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
_ensure_default_currency(db)
|
|
query = db.query(Currency)
|
|
if not include_inactive:
|
|
query = query.filter(Currency.is_active.is_(True))
|
|
currencies = query.order_by(Currency.code).all()
|
|
return currencies
|
|
|
|
|
|
@router.post("/", response_model=CurrencyRead, status_code=status.HTTP_201_CREATED)
|
|
def create_currency(payload: CurrencyCreate, db: Session = Depends(get_db)):
|
|
code = payload.code
|
|
existing = (
|
|
db.query(Currency)
|
|
.filter(Currency.code == code)
|
|
.one_or_none()
|
|
)
|
|
if existing is not None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=f"Currency '{code}' already exists",
|
|
)
|
|
|
|
currency = Currency(
|
|
code=code,
|
|
name=payload.name,
|
|
symbol=CurrencyBase._normalize_symbol(payload.symbol),
|
|
is_active=payload.is_active,
|
|
)
|
|
db.add(currency)
|
|
db.commit()
|
|
db.refresh(currency)
|
|
return currency
|
|
|
|
|
|
@router.put("/{code}", response_model=CurrencyRead)
|
|
def update_currency(code: str, payload: CurrencyUpdate, db: Session = Depends(get_db)):
|
|
currency = _get_currency_or_404(db, code)
|
|
|
|
if payload.name is not None:
|
|
setattr(currency, "name", payload.name)
|
|
if payload.symbol is not None or payload.symbol == "":
|
|
setattr(
|
|
currency,
|
|
"symbol",
|
|
CurrencyBase._normalize_symbol(payload.symbol),
|
|
)
|
|
if payload.is_active is not None:
|
|
code_value = getattr(currency, "code")
|
|
if code_value == DEFAULT_CURRENCY_CODE and payload.is_active is False:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="The default currency cannot be deactivated.",
|
|
)
|
|
setattr(currency, "is_active", payload.is_active)
|
|
|
|
db.add(currency)
|
|
db.commit()
|
|
db.refresh(currency)
|
|
return currency
|
|
|
|
|
|
@router.patch("/{code}/activation", response_model=CurrencyRead)
|
|
def toggle_currency_activation(code: str, body: CurrencyActivation, db: Session = Depends(get_db)):
|
|
currency = _get_currency_or_404(db, code)
|
|
code_value = getattr(currency, "code")
|
|
if code_value == DEFAULT_CURRENCY_CODE and body.is_active is False:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="The default currency cannot be deactivated.",
|
|
)
|
|
|
|
setattr(currency, "is_active", body.is_active)
|
|
db.add(currency)
|
|
db.commit()
|
|
db.refresh(currency)
|
|
return currency
|