mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 08:28:48 +02:00
Add user settings endpoint and exchange rate fallback APIs
Backend now stores user settings (dashboard config) in a JSONB column and exposes CRUD via /settings/. Exchange rate service gains multiple fallback providers (ExchangeRate-API, currency-api, FloatRates) so USD/CRC rates stay available when BCCR is down. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
59
backend/app/api/v1/endpoints/settings.py
Normal file
59
backend/app/api/v1/endpoints/settings.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.auth import get_current_user
|
||||
from app.db import get_session
|
||||
from app.models.models import UserSettings, UserSettingsRead, UserSettingsUpdate
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
DEFAULT_SETTINGS = {
|
||||
"dashboard": {
|
||||
"sections": {
|
||||
"crc_accounts": {"label": "CRC Accounts", "color": "primary", "cardColor": "primary", "visible": True, "order": 0, "expanded": False},
|
||||
"usd_accounts": {"label": "USD Accounts", "color": "chart-1", "cardColor": "chart-1", "visible": True, "order": 1, "expanded": False},
|
||||
"pension": {"label": "Pension", "color": "chart-2", "cardColor": "chart-2", "visible": True, "order": 2, "expanded": False},
|
||||
"savings": {"label": "Savings", "color": "chart-3", "cardColor": "chart-3", "visible": True, "order": 3, "expanded": False},
|
||||
"liabilities": {"label": "Liabilities", "color": "destructive", "cardColor": "destructive", "visible": True, "order": 4, "expanded": False},
|
||||
"crypto": {"label": "Crypto", "color": "chart-4", "cardColor": "chart-4", "visible": True, "order": 5, "expanded": False},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/", response_model=UserSettingsRead)
|
||||
def get_settings(
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
settings = session.exec(
|
||||
select(UserSettings).where(UserSettings.key == "default")
|
||||
).first()
|
||||
if not settings:
|
||||
settings = UserSettings(key="default", data=DEFAULT_SETTINGS)
|
||||
session.add(settings)
|
||||
session.commit()
|
||||
session.refresh(settings)
|
||||
return settings
|
||||
|
||||
|
||||
@router.patch("/", response_model=UserSettingsRead)
|
||||
def update_settings(
|
||||
body: UserSettingsUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
settings = session.exec(
|
||||
select(UserSettings).where(UserSettings.key == "default")
|
||||
).first()
|
||||
if not settings:
|
||||
settings = UserSettings(key="default", data=body.data)
|
||||
else:
|
||||
settings.data = body.data
|
||||
settings.updated_at = datetime.utcnow()
|
||||
session.add(settings)
|
||||
session.commit()
|
||||
session.refresh(settings)
|
||||
return settings
|
||||
@@ -7,6 +7,7 @@ from app.api.v1.endpoints import (
|
||||
categories,
|
||||
exchange_rate,
|
||||
import_transactions,
|
||||
settings,
|
||||
tokens,
|
||||
transactions,
|
||||
)
|
||||
@@ -20,3 +21,4 @@ api_router.include_router(import_transactions.router)
|
||||
api_router.include_router(exchange_rate.router)
|
||||
api_router.include_router(tokens.router)
|
||||
api_router.include_router(analytics.router)
|
||||
api_router.include_router(settings.router)
|
||||
|
||||
@@ -2,6 +2,8 @@ import enum
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Column
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlmodel import Field, Relationship, SQLModel
|
||||
|
||||
|
||||
@@ -195,3 +197,26 @@ class APITokenRead(SQLModel):
|
||||
created_at: datetime
|
||||
expires_at: Optional[datetime]
|
||||
is_active: bool
|
||||
|
||||
|
||||
# --- User Settings ---
|
||||
|
||||
|
||||
class UserSettings(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
key: str = Field(index=True, unique=True, default="default")
|
||||
data: dict = Field(
|
||||
default_factory=dict,
|
||||
sa_column=Column(JSONB, nullable=False, server_default="{}"),
|
||||
)
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
|
||||
class UserSettingsRead(SQLModel):
|
||||
key: str
|
||||
data: dict
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class UserSettingsUpdate(SQLModel):
|
||||
data: dict
|
||||
|
||||
@@ -10,7 +10,17 @@ from app.models.models import ExchangeRate
|
||||
# BCCR indicators: 317 = buy, 318 = sell
|
||||
BCCR_URL = "https://gee.bccr.fi.cr/Indicadores/Suscripciones/WS/wsindicadoreseconomicos.asmx/ObtenerIndicadoresEconomicos"
|
||||
|
||||
# Fallback APIs (no API key required, all support CRC)
|
||||
EXCHANGERATE_API_URL = "https://open.er-api.com/v6/latest/USD"
|
||||
CURRENCY_API_URL = "https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/usd.json"
|
||||
CURRENCY_API_FALLBACK_URL = "https://latest.currency-api.pages.dev/v1/currencies/usd.json"
|
||||
FLOATRATES_URL = "https://www.floatrates.com/daily/usd.json"
|
||||
|
||||
# Typical buy/sell spread for USD/CRC (~0.5% each side of mid-market)
|
||||
_SPREAD = 0.005
|
||||
|
||||
_cache: dict[str, tuple[ExchangeRate, datetime]] = {}
|
||||
_last_known: ExchangeRate | None = None # survives cache expiry — always holds the last successful rate
|
||||
CACHE_TTL = timedelta(hours=1)
|
||||
|
||||
|
||||
@@ -29,10 +39,7 @@ def _fetch_bccr_rate(indicator: int, date_str: str) -> float | None:
|
||||
resp = httpx.get(BCCR_URL, params=params, timeout=10)
|
||||
resp.raise_for_status()
|
||||
|
||||
# Parse XML response
|
||||
root = ET.fromstring(resp.text)
|
||||
# The value is in INGC011_DES_DATOS > NUM_VALOR
|
||||
ns = {"": "http://ws.sdde.bccr.fi.cr"}
|
||||
for datos in root.iter():
|
||||
if datos.tag.endswith("NUM_VALOR"):
|
||||
return float(datos.text.strip().replace(",", "."))
|
||||
@@ -41,14 +48,92 @@ def _fetch_bccr_rate(indicator: int, date_str: str) -> float | None:
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_bccr() -> tuple[float, float] | None:
|
||||
"""Try BCCR official API. Returns (buy, sell) or None."""
|
||||
today = datetime.now().strftime("%d/%m/%Y")
|
||||
buy = _fetch_bccr_rate(317, today)
|
||||
sell = _fetch_bccr_rate(318, today)
|
||||
if buy is not None and sell is not None:
|
||||
return (buy, sell)
|
||||
return None
|
||||
|
||||
|
||||
def _mid_to_buy_sell(mid: float) -> tuple[float, float]:
|
||||
"""Convert a mid-market rate to approximate buy/sell with a spread."""
|
||||
return (mid * (1 - _SPREAD), mid * (1 + _SPREAD))
|
||||
|
||||
|
||||
def _fetch_exchangerate_api() -> tuple[float, float] | None:
|
||||
"""Try ExchangeRate-API (open.er-api.com). No key required."""
|
||||
try:
|
||||
resp = httpx.get(EXCHANGERATE_API_URL, timeout=10)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get("result") == "success":
|
||||
crc = data["rates"].get("CRC")
|
||||
if crc:
|
||||
return _mid_to_buy_sell(float(crc))
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_currency_api() -> tuple[float, float] | None:
|
||||
"""Try fawazahmed0/currency-api (CDN-hosted). No key required."""
|
||||
for url in (CURRENCY_API_URL, CURRENCY_API_FALLBACK_URL):
|
||||
try:
|
||||
resp = httpx.get(url, timeout=10)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
crc = data.get("usd", {}).get("crc")
|
||||
if crc:
|
||||
return _mid_to_buy_sell(float(crc))
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_floatrates() -> tuple[float, float] | None:
|
||||
"""Try FloatRates. No key required."""
|
||||
try:
|
||||
resp = httpx.get(FLOATRATES_URL, timeout=10)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
crc_data = data.get("crc")
|
||||
if crc_data and "rate" in crc_data:
|
||||
return _mid_to_buy_sell(float(crc_data["rate"]))
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_rate_from_apis() -> tuple[float, float] | None:
|
||||
"""Try all sources in order: BCCR → ExchangeRate-API → currency-api → FloatRates."""
|
||||
for fetcher in (_fetch_bccr, _fetch_exchangerate_api, _fetch_currency_api, _fetch_floatrates):
|
||||
result = fetcher()
|
||||
if result is not None:
|
||||
return result
|
||||
return None
|
||||
|
||||
|
||||
def _remember(rate: ExchangeRate) -> ExchangeRate:
|
||||
"""Store rate in both TTL cache and permanent last-known holder."""
|
||||
global _last_known
|
||||
_cache["current"] = (rate, datetime.utcnow())
|
||||
_last_known = rate
|
||||
return rate
|
||||
|
||||
|
||||
def get_current_rate(session: Session) -> ExchangeRate | None:
|
||||
"""Get current USD/CRC rate. Uses in-memory cache + DB fallback."""
|
||||
# Check memory cache
|
||||
"""Get current USD/CRC rate. Never returns None once a rate has been fetched."""
|
||||
global _last_known
|
||||
|
||||
# 1. Fresh memory cache (< 1 hour)
|
||||
cached = _cache.get("current")
|
||||
if cached and datetime.utcnow() - cached[1] < CACHE_TTL:
|
||||
return cached[0]
|
||||
|
||||
# Check DB for recent rate
|
||||
# 2. Fresh DB rate (< 1 hour)
|
||||
one_hour_ago = datetime.utcnow() - CACHE_TTL
|
||||
db_rate = session.exec(
|
||||
select(ExchangeRate)
|
||||
@@ -56,31 +141,30 @@ def get_current_rate(session: Session) -> ExchangeRate | None:
|
||||
.order_by(col(ExchangeRate.fetched_at).desc())
|
||||
).first()
|
||||
if db_rate:
|
||||
_cache["current"] = (db_rate, datetime.utcnow())
|
||||
return db_rate
|
||||
return _remember(db_rate)
|
||||
|
||||
# Fetch from BCCR
|
||||
today = datetime.now().strftime("%d/%m/%Y")
|
||||
buy = _fetch_bccr_rate(317, today)
|
||||
sell = _fetch_bccr_rate(318, today)
|
||||
# 3. Try all API sources
|
||||
result = _fetch_rate_from_apis()
|
||||
if result is not None:
|
||||
buy, sell = result
|
||||
rate = ExchangeRate(date=datetime.utcnow(), buy_rate=buy, sell_rate=sell)
|
||||
session.add(rate)
|
||||
session.commit()
|
||||
session.refresh(rate)
|
||||
return _remember(rate)
|
||||
|
||||
if buy is None or sell is None:
|
||||
# Fallback: return most recent DB rate regardless of age
|
||||
fallback = session.exec(
|
||||
select(ExchangeRate).order_by(col(ExchangeRate.fetched_at).desc())
|
||||
).first()
|
||||
return fallback
|
||||
# 4. Stale DB rate (any age)
|
||||
fallback = session.exec(
|
||||
select(ExchangeRate).order_by(col(ExchangeRate.fetched_at).desc())
|
||||
).first()
|
||||
if fallback:
|
||||
return _remember(fallback)
|
||||
|
||||
rate = ExchangeRate(
|
||||
date=datetime.utcnow(),
|
||||
buy_rate=buy,
|
||||
sell_rate=sell,
|
||||
)
|
||||
session.add(rate)
|
||||
session.commit()
|
||||
session.refresh(rate)
|
||||
_cache["current"] = (rate, datetime.utcnow())
|
||||
return rate
|
||||
# 5. Last known in-memory rate (survives even if DB is empty)
|
||||
if _last_known:
|
||||
return _last_known
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_rate_history(session: Session, days: int = 30) -> list[ExchangeRate]:
|
||||
|
||||
Reference in New Issue
Block a user