-
Notifications
You must be signed in to change notification settings - Fork 6
Add multi currency support #43
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| """add_fx_rates_table | ||
|
|
||
| Revision ID: 50f63877083c | ||
| Revises: 94404b2e4890 | ||
| Create Date: 2025-10-03 16:21:30.722478 | ||
|
|
||
| """ | ||
| from typing import Sequence, Union | ||
|
|
||
| from alembic import op | ||
| import sqlalchemy as sa | ||
|
|
||
|
|
||
| # revision identifiers, used by Alembic. | ||
| revision: str = '50f63877083c' | ||
| down_revision: Union[str, Sequence[str], None] = '94404b2e4890' | ||
| branch_labels: Union[str, Sequence[str], None] = None | ||
| depends_on: Union[str, Sequence[str], None] = None | ||
|
|
||
|
|
||
| def upgrade() -> None: | ||
| """Upgrade schema.""" | ||
| op.create_table( | ||
| 'fx_rates', | ||
| sa.Column('id', sa.Integer(), nullable=False), | ||
| sa.Column('base', sa.String(), nullable=False), | ||
| sa.Column('rates', sa.JSON(), nullable=False), | ||
| sa.Column('fetched_at', sa.DateTime(), nullable=False), | ||
| sa.PrimaryKeyConstraint('id') | ||
| ) | ||
|
|
||
|
|
||
| def downgrade() -> None: | ||
| """Downgrade schema.""" | ||
| op.drop_table('fx_rates') |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| from sqlmodel import SQLModel, Field, Session, select | ||
| from sqlalchemy import Column, JSON | ||
| from typing import Optional, Literal | ||
| from datetime import datetime, UTC | ||
| from pydantic import BaseModel | ||
| import httpx | ||
|
|
||
|
|
||
| CurrencyCode = Literal["USD", "GBP", "EUR", "AUD", "MXN", "JPY"] | ||
|
|
||
| TTL_SECONDS = 21600 | ||
|
|
||
|
|
||
| class FxRates(SQLModel, table=True): | ||
| __tablename__ = "fx_rates" | ||
|
|
||
| id: Optional[int] = Field(default=None, primary_key=True) | ||
| base: str = Field(default="USD") | ||
| rates: dict = Field(sa_column=Column(JSON)) | ||
| fetched_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) | ||
|
|
||
|
|
||
| class RatesResponse(BaseModel): | ||
| base: str | ||
| rates: dict[str, float] | ||
| fetched_at: datetime | ||
| ttl_seconds: int | ||
| stale: bool | ||
|
|
||
|
|
||
| class FxService: | ||
| def __init__(self, session: Session): | ||
| self.session = session | ||
| self._cache: Optional[dict] = None | ||
| self._cache_fetched_at: Optional[datetime] = None | ||
|
|
||
| def get_rates(self) -> RatesResponse: | ||
| now = datetime.now(UTC) | ||
|
|
||
| if self._is_cache_valid(now) and self._cache is not None and self._cache_fetched_at is not None: | ||
| return self._build_response( | ||
| self._cache, | ||
| self._cache_fetched_at, | ||
| stale=False | ||
| ) | ||
|
|
||
| try: | ||
| fresh_rates = self.fetch_from_provider() | ||
| self._cache = fresh_rates | ||
| self._cache_fetched_at = now | ||
|
|
||
| self._persist_to_db(fresh_rates, now) | ||
|
|
||
| return self._build_response(fresh_rates, now, stale=False) | ||
| except Exception: | ||
| db_rates = self._get_from_db() | ||
| if db_rates: | ||
| self._cache = db_rates["rates"] | ||
| self._cache_fetched_at = db_rates["fetched_at"] | ||
| return self._build_response( | ||
| db_rates["rates"], | ||
| db_rates["fetched_at"], | ||
| stale=True | ||
| ) | ||
|
|
||
| return self._build_response( | ||
| {}, | ||
| now, | ||
| stale=True | ||
| ) | ||
|
|
||
| def _is_cache_valid(self, now: datetime) -> bool: | ||
| if self._cache is None or self._cache_fetched_at is None: | ||
| return False | ||
|
|
||
| age = (now - self._cache_fetched_at).total_seconds() | ||
| return age < TTL_SECONDS | ||
|
|
||
| def fetch_from_provider(self) -> dict: | ||
| url = "https://api.frankfurter.app/latest" | ||
| params = { | ||
| "from": "USD", | ||
| "to": "GBP,EUR,AUD,MXN,JPY" | ||
| } | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing error handling: |
||
| response = httpx.get(url, params=params, timeout=10.0) | ||
| response.raise_for_status() | ||
|
|
||
| data = response.json() | ||
| return data["rates"] | ||
|
|
||
| def _persist_to_db(self, rates: dict, fetched_at: datetime) -> None: | ||
| fx_rates = FxRates( | ||
| base="USD", | ||
| rates=rates, | ||
| fetched_at=fetched_at | ||
| ) | ||
| self.session.add(fx_rates) | ||
| self.session.commit() | ||
|
|
||
| def _get_from_db(self) -> Optional[dict]: | ||
| stmt = select(FxRates).order_by(FxRates.fetched_at.desc()).limit(1) # type: ignore[attr-defined] | ||
| result = self.session.exec(stmt).first() | ||
|
|
||
| if result: | ||
| return { | ||
| "rates": result.rates, | ||
| "fetched_at": result.fetched_at | ||
| } | ||
| return None | ||
|
|
||
| def _build_response( | ||
| self, | ||
| rates: dict, | ||
| fetched_at: datetime, | ||
| stale: bool | ||
| ) -> RatesResponse: | ||
| all_rates = {"USD": 1.0, **rates} | ||
|
|
||
| return RatesResponse( | ||
| base="USD", | ||
| rates=all_rates, | ||
| fetched_at=fetched_at, | ||
| ttl_seconds=TTL_SECONDS, | ||
| stale=stale | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,6 +19,7 @@ | |
| ) | ||
| from . import crud | ||
| from .models import Product, DeliveryOption, Category, ProductDeliveryLink | ||
| from .currency import FxService, RatesResponse | ||
|
|
||
| def calculate_delivery_summary(delivery_options: List[DeliveryOption]) -> Optional[DeliverySummary]: | ||
| """Calculate delivery summary from a list of delivery options""" | ||
|
|
@@ -427,6 +428,11 @@ def get_product_image( | |
| } | ||
| ) | ||
|
|
||
| @app.get("/fx/rates", response_model=RatesResponse) | ||
| def get_fx_rates(session: Session = Depends(get_session)): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Performance issue: Creating a new FxService instance on every request defeats the purpose of the in-memory cache ( |
||
| service = FxService(session) | ||
| return service.get_rates() | ||
|
|
||
| if __name__ == "__main__": | ||
| import uvicorn | ||
| uvicorn.run(app, host="0.0.0.0", port=8001) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Potential bug: Using
default_factoryforfetched_atcan cause issues when loading existing records from the database. SQLModel might re-evaluate this factory when hydrating objects, potentially overwriting the actual stored timestamp. Consider removing the default and setting this explicitly when creating new records, or use SQLAlchemy'sserver_defaultif you want database-level defaults.