From 1da927dc07d81d28f885f743854b27b9b941505a Mon Sep 17 00:00:00 2001 From: ribardej Date: Tue, 11 Nov 2025 14:50:43 +0100 Subject: [PATCH 01/10] fix(tests): fixed test runtime errors regarding database connection --- 7project/backend/app/app.py | 11 ++++++++++- 7project/backend/app/core/db.py | 24 ++++++++++++++++-------- 7project/backend/app/services/db.py | 11 ++++++++++- 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/7project/backend/app/app.py b/7project/backend/app/app.py index 6bb0e5d..84414d7 100644 --- a/7project/backend/app/app.py +++ b/7project/backend/app/app.py @@ -28,7 +28,8 @@ from app.services.user_service import SECRET from fastapi import FastAPI import sentry_sdk from fastapi_users.db import SQLAlchemyUserDatabase -from app.core.db import async_session_maker +from app.core.db import async_session_maker, engine +from app.core.base import Base sentry_sdk.init( dsn=os.getenv("SENTRY_DSN"), @@ -37,6 +38,14 @@ sentry_sdk.init( fastApi = FastAPI() +@fastApi.on_event("startup") +async def on_startup(): + # Ensure DB schema is created for tests/dev + from sqlalchemy.ext.asyncio import AsyncEngine + from sqlalchemy import text + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + # CORS for frontend dev server fastApi.add_middleware( CORSMiddleware, diff --git a/7project/backend/app/core/db.py b/7project/backend/app/core/db.py index 1186352..5a20cb1 100644 --- a/7project/backend/app/core/db.py +++ b/7project/backend/app/core/db.py @@ -1,27 +1,35 @@ import os +from urllib.parse import urlparse from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker from app.core.base import Base +# Determine DATABASE_URL with sensible defaults for local testing DATABASE_URL = os.getenv("DATABASE_URL") if not DATABASE_URL: - mariadb_host = os.getenv("MARIADB_HOST", "localhost") + mariadb_host = os.getenv("MARIADB_HOST") mariadb_port = os.getenv("MARIADB_PORT", "3306") - mariadb_db = os.getenv("MARIADB_DB", "group_project") - mariadb_user = os.getenv("MARIADB_USER", "root") - mariadb_password = os.getenv("MARIADB_PASSWORD", "strongpassword") + mariadb_db = os.getenv("MARIADB_DB") + mariadb_user = os.getenv("MARIADB_USER") + mariadb_password = os.getenv("MARIADB_PASSWORD") if mariadb_host and mariadb_db and mariadb_user and mariadb_password: DATABASE_URL = f"mysql+asyncmy://{mariadb_user}:{mariadb_password}@{mariadb_host}:{mariadb_port}/{mariadb_db}" else: - raise Exception("Only MariaDB is supported. Please set the DATABASE_URL environment variable.") + # Default to local SQLite for tests/development when nothing is configured + DATABASE_URL = os.getenv("SQLITE_URL", "sqlite+aiosqlite:///./test.db") # Load all models to register them from app.models.user import User from app.models.transaction import Transaction from app.models.categories import Category -host_env = os.getenv("MARIADB_HOST", "localhost") -ssl_enabled = host_env not in {"localhost", "127.0.0.1"} -connect_args = {"ssl": {"ssl": True}} if ssl_enabled else {} +# Configure connect args based on backend +parsed = urlparse(DATABASE_URL) +scheme = parsed.scheme +connect_args = {} +if scheme.startswith("mysql"): + host_env = os.getenv("MARIADB_HOST", parsed.hostname or "localhost") + ssl_enabled = host_env not in {"localhost", "127.0.0.1"} + connect_args = {"ssl": {"ssl": True}} if ssl_enabled else {} engine = create_async_engine( DATABASE_URL, diff --git a/7project/backend/app/services/db.py b/7project/backend/app/services/db.py index 606af8d..65d1dae 100644 --- a/7project/backend/app/services/db.py +++ b/7project/backend/app/services/db.py @@ -3,11 +3,20 @@ from fastapi import Depends from sqlalchemy.ext.asyncio import AsyncSession from fastapi_users.db import SQLAlchemyUserDatabase -from ..core.db import async_session_maker +from ..core.db import async_session_maker, engine +from ..core.base import Base from ..models.user import User, OAuthAccount +_initialized = False + async def get_async_session() -> AsyncGenerator[AsyncSession, None]: + global _initialized + if not _initialized: + # Lazily ensure tables exist; helpful for test runs without migrations + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + _initialized = True async with async_session_maker() as session: yield session From ff9cc712dbe445f7995f976713c0f4ca1d307985 Mon Sep 17 00:00:00 2001 From: ribardej Date: Tue, 11 Nov 2025 15:05:44 +0100 Subject: [PATCH 02/10] fix(tests): fixed test runtime errors regarding database connection --- .github/workflows/run-tests.yml | 2 ++ 7project/backend/tests/conftest.py | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index ddd0337..077e23f 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -31,6 +31,8 @@ jobs: MARIADB_DB: group_project MARIADB_USER: appuser MARIADB_PASSWORD: apppass + # Ensure the application uses MariaDB (async) during tests + DATABASE_URL: mysql+asyncmy://appuser:apppass@127.0.0.1:3306/group_project steps: - name: Check out repository code diff --git a/7project/backend/tests/conftest.py b/7project/backend/tests/conftest.py index 596aced..0f29b38 100644 --- a/7project/backend/tests/conftest.py +++ b/7project/backend/tests/conftest.py @@ -19,8 +19,9 @@ def fastapi_app(): return app -@pytest.fixture(scope="session") +@pytest.fixture(scope="function") def client(fastapi_app): + # Function-scoped to avoid leaking loop-bound resources into async tests return TestClient(fastapi_app, raise_server_exceptions=True) From 67b44539f2dbb70299fa4cc81640e76d0ab26b0a Mon Sep 17 00:00:00 2001 From: ribardej Date: Tue, 11 Nov 2025 15:12:13 +0100 Subject: [PATCH 03/10] fix(tests): fixed test runtime errors regarding database connection --- 7project/backend/pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/7project/backend/pyproject.toml b/7project/backend/pyproject.toml index afdd586..3ad2a75 100644 --- a/7project/backend/pyproject.toml +++ b/7project/backend/pyproject.toml @@ -1,5 +1,5 @@ [tool.pytest.ini_options] pythonpath = "." asyncio_mode = "auto" -asyncio_default_fixture_loop_scope = "session" -asyncio_default_test_loop_scope = "session" \ No newline at end of file +asyncio_default_fixture_loop_scope = "function" +asyncio_default_test_loop_scope = "function" \ No newline at end of file From 3d26ed6a62061993efca442ae14cf153e952456c Mon Sep 17 00:00:00 2001 From: ribardej Date: Tue, 11 Nov 2025 15:27:03 +0100 Subject: [PATCH 04/10] fix(tests): fixed test runtime errors regarding database connection --- .github/workflows/run-tests.yml | 1 + 7project/backend/app/app.py | 19 +++++++++---------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 077e23f..024be54 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -33,6 +33,7 @@ jobs: MARIADB_PASSWORD: apppass # Ensure the application uses MariaDB (async) during tests DATABASE_URL: mysql+asyncmy://appuser:apppass@127.0.0.1:3306/group_project + DISABLE_METRICS: "1" steps: - name: Check out repository code diff --git a/7project/backend/app/app.py b/7project/backend/app/app.py index 84414d7..eb3fa14 100644 --- a/7project/backend/app/app.py +++ b/7project/backend/app/app.py @@ -59,16 +59,15 @@ fastApi.add_middleware( allow_headers=["*"], ) -prometheus = Instrumentator().instrument(fastApi) - -# Register custom metrics -prometheus.add(number_of_users()).add(number_of_transactions()) - -prometheus.expose( - fastApi, - endpoint="/metrics", - include_in_schema=True, -) +if os.getenv("DISABLE_METRICS") != "1": + prometheus = Instrumentator().instrument(fastApi) + # Register custom metrics + prometheus.add(number_of_users()).add(number_of_transactions()) + prometheus.expose( + fastApi, + endpoint="/metrics", + include_in_schema=True, + ) fastApi.include_router(auth_router) fastApi.include_router(categories_router) From 8b92b9bd18d1bdd1421f8153e02b485388103b40 Mon Sep 17 00:00:00 2001 From: ribardej Date: Tue, 11 Nov 2025 15:28:48 +0100 Subject: [PATCH 05/10] fix(tests): fixed test runtime errors regarding database connection --- 7project/backend/pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/7project/backend/pyproject.toml b/7project/backend/pyproject.toml index 3ad2a75..afdd586 100644 --- a/7project/backend/pyproject.toml +++ b/7project/backend/pyproject.toml @@ -1,5 +1,5 @@ [tool.pytest.ini_options] pythonpath = "." asyncio_mode = "auto" -asyncio_default_fixture_loop_scope = "function" -asyncio_default_test_loop_scope = "function" \ No newline at end of file +asyncio_default_fixture_loop_scope = "session" +asyncio_default_test_loop_scope = "session" \ No newline at end of file From 5954e569564da77675c8121ff9b06881dfe76dde Mon Sep 17 00:00:00 2001 From: ribardej Date: Tue, 11 Nov 2025 16:01:11 +0100 Subject: [PATCH 06/10] feat(backend): Moved the unirate API to the backend --- .github/workflows/deploy-prod.yaml | 4 +- 7project/backend/app/api/exchange_rates.py | 66 +++++++++++++++++++ 7project/backend/app/app.py | 2 + .../myapp-chart/templates/app-deployment.yaml | 5 ++ .../charts/myapp-chart/templates/prod.yaml | 1 + 7project/charts/myapp-chart/values.yaml | 3 + 7project/frontend/src/pages/Dashboard.tsx | 55 ++++------------ 7 files changed, 93 insertions(+), 43 deletions(-) create mode 100644 7project/backend/app/api/exchange_rates.py diff --git a/.github/workflows/deploy-prod.yaml b/.github/workflows/deploy-prod.yaml index 3591be9..dbc419d 100644 --- a/.github/workflows/deploy-prod.yaml +++ b/.github/workflows/deploy-prod.yaml @@ -99,6 +99,7 @@ jobs: SMTP_USE_TLS: ${{ secrets.SMTP_USE_TLS }} SMTP_USE_SSL: ${{ secrets.SMTP_USE_SSL }} SMTP_FROM: ${{ secrets.SMTP_FROM }} + UNIRATE_API_KEY: ${{ secrets.UNIRATE_API_KEY }} run: | helm upgrade --install myapp ./7project/charts/myapp-chart \ -n prod --create-namespace \ @@ -125,4 +126,5 @@ jobs: --set-string smtp.password="$SMTP_PASSWORD" \ --set-string smtp.tls="$SMTP_USE_TLS" \ --set-string smtp.ssl="$SMTP_USE_SSL" \ - --set-string smtp.from="$SMTP_FROM" \ No newline at end of file + --set-string smtp.from="$SMTP_FROM" \ + --set-string unirate.key="$UNIRATE_API_KEY" \ No newline at end of file diff --git a/7project/backend/app/api/exchange_rates.py b/7project/backend/app/api/exchange_rates.py new file mode 100644 index 0000000..d8038b1 --- /dev/null +++ b/7project/backend/app/api/exchange_rates.py @@ -0,0 +1,66 @@ +import os +from typing import List + +import httpx +from fastapi import APIRouter, HTTPException, Query, status + +router = APIRouter(prefix="/exchange-rates", tags=["exchange-rates"]) + + +@router.get("/", status_code=status.HTTP_200_OK) +async def get_exchange_rates(symbols: str = Query("EUR,USD,NOK", description="Comma-separated currency codes to fetch vs CZK")): + """ + Fetch exchange rates from UniRate API on the backend and return CZK-per-target rates. + - Always requests CZK in addition to requested symbols to compute conversion from USD-base. + - Returns a list of {currencyCode, rate} where rate is CZK per 1 unit of the target currency. + """ + api_key = os.getenv("UNIRATE_API_KEY") + if not api_key: + raise HTTPException(status_code=500, detail="Server is not configured with UNIRATE_API_KEY") + + # Ensure CZK is included for conversion + requested = [s.strip().upper() for s in symbols.split(",") if s.strip()] + if "CZK" not in requested: + requested.append("CZK") + query_symbols = ",".join(sorted(set(requested))) + + url = f"https://unirateapi.com/api/rates?api_key={api_key}&symbols={query_symbols}" + + try: + async with httpx.AsyncClient(timeout=httpx.Timeout(15.0)) as client: + resp = await client.get(url) + if resp.status_code != httpx.codes.OK: + raise HTTPException(status_code=502, detail=f"Upstream UniRate error: HTTP {resp.status_code}") + data = resp.json() + except httpx.HTTPError as e: + raise HTTPException(status_code=502, detail=f"Failed to contact UniRate: {str(e)}") + + # Validate response structure + rates = data.get("rates") if isinstance(data, dict) else None + base = data.get("base") if isinstance(data, dict) else None + if not rates or base != "USD" or "CZK" not in rates: + # Prefer upstream message when available + detail = data.get("message") if isinstance(data, dict) else None + if not detail and isinstance(data, dict): + err = data.get("error") + if isinstance(err, dict): + detail = err.get("info") + raise HTTPException(status_code=502, detail=detail or "Invalid response from UniRate API") + + czk_per_usd = rates["CZK"] + + # Build result excluding CZK itself + result = [] + for code in requested: + if code == "CZK": + continue + target_per_usd = rates.get(code) + if target_per_usd in (None, 0): + # Skip unavailable or invalid + continue + czk_per_target = czk_per_usd / target_per_usd + result.append({"currencyCode": code, "rate": czk_per_target}) + + return result + + diff --git a/7project/backend/app/app.py b/7project/backend/app/app.py index 4ea49f1..f312bb7 100644 --- a/7project/backend/app/app.py +++ b/7project/backend/app/app.py @@ -21,6 +21,7 @@ from app.api.auth import router as auth_router from app.api.csas import router as csas_router from app.api.categories import router as categories_router from app.api.transactions import router as transactions_router +from app.api.exchange_rates import router as exchange_rates_router from app.services.user_service import auth_backend, current_active_verified_user, fastapi_users, get_oauth_provider, \ UserManager, get_jwt_strategy from app.core.security import extract_bearer_token, is_token_revoked, decode_and_verify_jwt @@ -74,6 +75,7 @@ if not os.getenv("PYTEST_RUN_CONFIG"): fastApi.include_router(auth_router) fastApi.include_router(categories_router) fastApi.include_router(transactions_router) +fastApi.include_router(exchange_rates_router) for h in list(logging.root.handlers): logging.root.removeHandler(h) diff --git a/7project/charts/myapp-chart/templates/app-deployment.yaml b/7project/charts/myapp-chart/templates/app-deployment.yaml index 876e6f9..3c41c05 100644 --- a/7project/charts/myapp-chart/templates/app-deployment.yaml +++ b/7project/charts/myapp-chart/templates/app-deployment.yaml @@ -90,6 +90,11 @@ spec: secretKeyRef: name: prod key: CSAS_CLIENT_SECRET + - name: UNIRATE_API_KEY + valueFrom: + secretKeyRef: + name: prod + key: UNIRATE_API_KEY - name: DOMAIN value: {{ required "Set .Values.domain" .Values.domain | quote }} - name: DOMAIN_SCHEME diff --git a/7project/charts/myapp-chart/templates/prod.yaml b/7project/charts/myapp-chart/templates/prod.yaml index 01f76ef..ba0df7a 100644 --- a/7project/charts/myapp-chart/templates/prod.yaml +++ b/7project/charts/myapp-chart/templates/prod.yaml @@ -26,3 +26,4 @@ stringData: SMTP_USE_TLS: {{ .Values.smtp.tls | default false | quote }} SMTP_USE_SSL: {{ .Values.smtp.ssl | default false | quote }} SMTP_FROM: {{ .Values.smtp.from | default "" | quote }} + UNIRATE_API_KEY: {{ .Values.unirate.key | default "" | quote }} diff --git a/7project/charts/myapp-chart/values.yaml b/7project/charts/myapp-chart/values.yaml index cbffb51..f3c2dc5 100644 --- a/7project/charts/myapp-chart/values.yaml +++ b/7project/charts/myapp-chart/values.yaml @@ -13,6 +13,9 @@ deployment: "" domain: "" domain_scheme: "" +unirate: + key: "" + frontend_domain: "" frontend_domain_scheme: "" diff --git a/7project/frontend/src/pages/Dashboard.tsx b/7project/frontend/src/pages/Dashboard.tsx index 4aa2166..83bf582 100644 --- a/7project/frontend/src/pages/Dashboard.tsx +++ b/7project/frontend/src/pages/Dashboard.tsx @@ -6,7 +6,7 @@ import BalanceChart from './BalanceChart'; import ManualManagement from './ManualManagement'; import CategoryPieChart from './CategoryPieChart'; import MockBankModal, { type MockGenerationOptions } from './MockBankModal'; -import { BACKEND_URL, VITE_UNIRATE_API_KEY } from '../config'; +import { BACKEND_URL } from '../config'; function formatAmount(n: number) { return new Intl.NumberFormat(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n); @@ -45,49 +45,20 @@ function CurrencyRates() { setLoading(true); setError(null); - const API_KEY = VITE_UNIRATE_API_KEY; - - // We need to get the CZK rate as well, to use it for conversion - const allSymbols = [...TARGET_CURRENCIES, 'CZK'].join(','); - - // We remove the `base` param, as the API seems to force base=USD - const UNIRATE_API_URL = `https://unirateapi.com/api/rates?api_key=${API_KEY}&symbols=${allSymbols}`; - try { - const res = await fetch(UNIRATE_API_URL); - const data: UnirateApiResponse = await res.json(); - - // --- THIS IS THE NEW, CORRECTED LOGIC --- - - // 1. Check if the 'rates' object exists. If not, it's an error. - if (!data.rates) { - let errorMessage = data.message || (data.error ? data.error.info : 'Invalid API response'); - throw new Error(errorMessage || 'Could not load rates'); - } - - // 2. Check that we got the base currency (USD) and our conversion currency (CZK) - if (data.base !== 'USD' || !data.rates.CZK) { - throw new Error('API response is missing required data for conversion (USD or CZK)'); - } - - // 3. Get our main conversion factor - const czkPerUsd = data.rates.CZK; // e.g., 23.0 - - // 4. Calculate the rates for our target currencies - const formattedRates = TARGET_CURRENCIES.map(code => { - const targetPerUsd = data.rates[code]; // e.g., 0.9 for EUR - - // This calculates: (CZK per USD) / (TARGET per USD) = CZK per TARGET - // e.g. (23.0 CZK / 1 USD) / (0.9 EUR / 1 USD) = 25.55 CZK / 1 EUR - const rate = czkPerUsd / targetPerUsd; - - return { - currencyCode: code, - rate: rate, - }; + const base = BACKEND_URL.replace(/\/$/, ''); + const url = `${base}/exchange-rates?symbols=${TARGET_CURRENCIES.join(',')}`; + const token = localStorage.getItem('token'); + const res = await fetch(url, { + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + credentials: 'include', }); - - setRates(formattedRates); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || `Failed to load rates (${res.status})`); + } + const data: RateData[] = await res.json(); + setRates(data); } catch (err: any) { setError(err.message || 'Could not load rates'); } finally { From 3cdefc33fc3398ee79f5e7a79af36457419178aa Mon Sep 17 00:00:00 2001 From: ribardej Date: Tue, 11 Nov 2025 16:02:37 +0100 Subject: [PATCH 07/10] feat(backend): updated deploy-pr.yaml --- .github/workflows/deploy-pr.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-pr.yaml b/.github/workflows/deploy-pr.yaml index 5a488a1..2874b5e 100644 --- a/.github/workflows/deploy-pr.yaml +++ b/.github/workflows/deploy-pr.yaml @@ -85,6 +85,7 @@ jobs: DOMAIN_SCHEME: "${{ needs.get_urls.outputs.backend_url_scheme }}" FRONTEND_DOMAIN: "${{ needs.get_urls.outputs.frontend_url }}" FRONTEND_DOMAIN_SCHEME: "${{ needs.get_urls.outputs.frontend_url_scheme }}" + UNIRATE_API_KEY: ${{ secrets.UNIRATE_API_KEY }} run: | PR=${{ github.event.pull_request.number }} RELEASE=myapp-pr-$PR @@ -102,7 +103,8 @@ jobs: --set-string rabbitmq.password="$RABBITMQ_PASSWORD" \ --set-string database.password="$DB_PASSWORD" \ --set-string database.encryptionSecret="$PR" \ - --set-string app.name="finance-tracker-pr-$PR" + --set-string app.name="finance-tracker-pr-$PR" \ + --set-string unirate.key="$UNIRATE_API_KEY" - name: Post preview URLs as PR comment uses: actions/github-script@v7 From 25e587cea867a46d130e861789d0154efbf229ad Mon Sep 17 00:00:00 2001 From: ribardej Date: Tue, 11 Nov 2025 16:28:12 +0100 Subject: [PATCH 08/10] fix(db): updated db setup for tests --- 7project/backend/app/app.py | 8 -------- 7project/backend/app/core/db.py | 24 ++++++++---------------- 7project/backend/app/services/db.py | 11 +---------- 7project/backend/tests/conftest.py | 3 +-- 4 files changed, 10 insertions(+), 36 deletions(-) diff --git a/7project/backend/app/app.py b/7project/backend/app/app.py index f312bb7..e692435 100644 --- a/7project/backend/app/app.py +++ b/7project/backend/app/app.py @@ -40,14 +40,6 @@ sentry_sdk.init( fastApi = FastAPI() -@fastApi.on_event("startup") -async def on_startup(): - # Ensure DB schema is created for tests/dev - from sqlalchemy.ext.asyncio import AsyncEngine - from sqlalchemy import text - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - # CORS for frontend dev server fastApi.add_middleware( CORSMiddleware, diff --git a/7project/backend/app/core/db.py b/7project/backend/app/core/db.py index 5a20cb1..1186352 100644 --- a/7project/backend/app/core/db.py +++ b/7project/backend/app/core/db.py @@ -1,35 +1,27 @@ import os -from urllib.parse import urlparse from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker from app.core.base import Base -# Determine DATABASE_URL with sensible defaults for local testing DATABASE_URL = os.getenv("DATABASE_URL") if not DATABASE_URL: - mariadb_host = os.getenv("MARIADB_HOST") + mariadb_host = os.getenv("MARIADB_HOST", "localhost") mariadb_port = os.getenv("MARIADB_PORT", "3306") - mariadb_db = os.getenv("MARIADB_DB") - mariadb_user = os.getenv("MARIADB_USER") - mariadb_password = os.getenv("MARIADB_PASSWORD") + mariadb_db = os.getenv("MARIADB_DB", "group_project") + mariadb_user = os.getenv("MARIADB_USER", "root") + mariadb_password = os.getenv("MARIADB_PASSWORD", "strongpassword") if mariadb_host and mariadb_db and mariadb_user and mariadb_password: DATABASE_URL = f"mysql+asyncmy://{mariadb_user}:{mariadb_password}@{mariadb_host}:{mariadb_port}/{mariadb_db}" else: - # Default to local SQLite for tests/development when nothing is configured - DATABASE_URL = os.getenv("SQLITE_URL", "sqlite+aiosqlite:///./test.db") + raise Exception("Only MariaDB is supported. Please set the DATABASE_URL environment variable.") # Load all models to register them from app.models.user import User from app.models.transaction import Transaction from app.models.categories import Category -# Configure connect args based on backend -parsed = urlparse(DATABASE_URL) -scheme = parsed.scheme -connect_args = {} -if scheme.startswith("mysql"): - host_env = os.getenv("MARIADB_HOST", parsed.hostname or "localhost") - ssl_enabled = host_env not in {"localhost", "127.0.0.1"} - connect_args = {"ssl": {"ssl": True}} if ssl_enabled else {} +host_env = os.getenv("MARIADB_HOST", "localhost") +ssl_enabled = host_env not in {"localhost", "127.0.0.1"} +connect_args = {"ssl": {"ssl": True}} if ssl_enabled else {} engine = create_async_engine( DATABASE_URL, diff --git a/7project/backend/app/services/db.py b/7project/backend/app/services/db.py index 65d1dae..606af8d 100644 --- a/7project/backend/app/services/db.py +++ b/7project/backend/app/services/db.py @@ -3,20 +3,11 @@ from fastapi import Depends from sqlalchemy.ext.asyncio import AsyncSession from fastapi_users.db import SQLAlchemyUserDatabase -from ..core.db import async_session_maker, engine -from ..core.base import Base +from ..core.db import async_session_maker from ..models.user import User, OAuthAccount -_initialized = False - async def get_async_session() -> AsyncGenerator[AsyncSession, None]: - global _initialized - if not _initialized: - # Lazily ensure tables exist; helpful for test runs without migrations - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - _initialized = True async with async_session_maker() as session: yield session diff --git a/7project/backend/tests/conftest.py b/7project/backend/tests/conftest.py index 0f29b38..596aced 100644 --- a/7project/backend/tests/conftest.py +++ b/7project/backend/tests/conftest.py @@ -19,9 +19,8 @@ def fastapi_app(): return app -@pytest.fixture(scope="function") +@pytest.fixture(scope="session") def client(fastapi_app): - # Function-scoped to avoid leaking loop-bound resources into async tests return TestClient(fastapi_app, raise_server_exceptions=True) From 2e1dddb4f8802feb009c55b166fed2b11f8c3f9c Mon Sep 17 00:00:00 2001 From: ribardej Date: Tue, 11 Nov 2025 16:30:34 +0100 Subject: [PATCH 09/10] fix(frontend): fixed dashboard error --- 7project/frontend/src/pages/Dashboard.tsx | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/7project/frontend/src/pages/Dashboard.tsx b/7project/frontend/src/pages/Dashboard.tsx index 83bf582..5252a20 100644 --- a/7project/frontend/src/pages/Dashboard.tsx +++ b/7project/frontend/src/pages/Dashboard.tsx @@ -21,17 +21,6 @@ type RateData = { rate: number; }; -// The part of the API response structure we need -type UnirateApiResponse = { - base: string; - rates: { [key: string]: number }; - // We'll also check for error formats just in case - message?: string; - error?: { - info: string; - }; -}; - // The currencies you want to display const TARGET_CURRENCIES = ['EUR', 'USD', 'NOK']; From ef26e887132fc5a6a9b55447f0578605d654bab2 Mon Sep 17 00:00:00 2001 From: ribardej Date: Tue, 11 Nov 2025 18:47:35 +0100 Subject: [PATCH 10/10] feat(backend): moved mock bank to backend --- 7project/backend/app/api/mock_bank.py | 143 ++++++++++++++++++++++ 7project/backend/app/app.py | 2 + 7project/frontend/src/pages/Dashboard.tsx | 78 ++++++------ 3 files changed, 187 insertions(+), 36 deletions(-) create mode 100644 7project/backend/app/api/mock_bank.py diff --git a/7project/backend/app/api/mock_bank.py b/7project/backend/app/api/mock_bank.py new file mode 100644 index 0000000..551756b --- /dev/null +++ b/7project/backend/app/api/mock_bank.py @@ -0,0 +1,143 @@ +from datetime import datetime, timedelta +from typing import List, Optional +import random + +from fastapi import APIRouter, Depends, Response, status +from pydantic import BaseModel, Field, conint, confloat, validator +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.services.db import get_async_session +from app.services.user_service import current_active_user +from app.models.user import User +from app.models.transaction import Transaction +from app.models.categories import Category +from app.schemas.transaction import TransactionRead + +router = APIRouter(prefix="/mock-bank", tags=["mock-bank"]) + + +class GenerateOptions(BaseModel): + count: conint(strict=True, gt=0) = Field(default=10, description="Number of transactions to generate") + minAmount: confloat(strict=True) = Field(default=-200.0, description="Minimum transaction amount") + maxAmount: confloat(strict=True) = Field(default=200.0, description="Maximum transaction amount") + startDate: Optional[str] = Field(None, description="Earliest date (YYYY-MM-DD)") + endDate: Optional[str] = Field(None, description="Latest date (YYYY-MM-DD)") + categoryIds: List[int] = Field(default_factory=list, description="Optional category IDs to assign randomly") + + @validator("maxAmount") + def _validate_amounts(cls, v, values): + min_amt = values.get("minAmount") + if min_amt is not None and v < min_amt: + raise ValueError("maxAmount must be greater than or equal to minAmount") + return v + + @validator("endDate") + def _validate_dates(cls, v, values): + sd = values.get("startDate") + if v and sd: + try: + ed = datetime.strptime(v, "%Y-%m-%d").date() + st = datetime.strptime(sd, "%Y-%m-%d").date() + except ValueError: + raise ValueError("Invalid date format, expected YYYY-MM-DD") + if ed < st: + raise ValueError("endDate must be greater than or equal to startDate") + return v + + +class GeneratedTransaction(BaseModel): + amount: float + date: str # YYYY-MM-DD + category_ids: List[int] = [] + description: Optional[str] = None + + +@router.post("/generate", response_model=List[GeneratedTransaction]) +async def generate_mock_transactions( + options: GenerateOptions, + user: User = Depends(current_active_user), +): + # Seed randomness per user to make results less erratic across multiple calls in quick succession + seed = int(datetime.utcnow().timestamp()) ^ int(user.id) + rnd = random.Random(seed) + + # Determine date range + if options.startDate: + start_date = datetime.strptime(options.startDate, "%Y-%m-%d").date() + else: + start_date = (datetime.utcnow() - timedelta(days=365)).date() + if options.endDate: + end_date = datetime.strptime(options.endDate, "%Y-%m-%d").date() + else: + end_date = datetime.utcnow().date() + + span_days = max(0, (end_date - start_date).days) + + results: List[GeneratedTransaction] = [] + for _ in range(options.count): + amount = round(rnd.uniform(options.minAmount, options.maxAmount), 2) + # Pick a random date in the inclusive range + rand_day = rnd.randint(0, span_days) if span_days > 0 else 0 + tx_date = start_date + timedelta(days=rand_day) + # Pick category randomly from provided list, or empty + if options.categoryIds: + cat = [rnd.choice(options.categoryIds)] + else: + cat = [] + # Optional simple description for flavor + desc = None + # Assemble + results.append(GeneratedTransaction( + amount=amount, + date=tx_date.isoformat(), + category_ids=cat, + description=desc, + )) + + return results + + + +@router.get("/scrape", response_model=Optional[TransactionRead]) +async def scrape_mock_bank( + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + # 95% of the time: nothing to scrape + if random.random() < 0.95: + return Response(status_code=status.HTTP_204_NO_CONTENT) + + # 5% chance: create a new transaction and return it + amount = round(random.uniform(-200.0, 200.0), 2) + tx_date = datetime.utcnow().date() + + # Optionally attach a random category owned by this user (if any) + res = await session.execute(select(Category).where(Category.user_id == user.id)) + user_categories = list(res.scalars()) + chosen_categories = [] + if user_categories: + chosen_categories = [random.choice(user_categories)] + + # Build and persist transaction + tx = Transaction( + amount=amount, + description="Mock bank scrape", + user_id=user.id, + date=tx_date, + ) + if chosen_categories: + tx.categories = chosen_categories + + session.add(tx) + await session.commit() + await session.refresh(tx) + await session.refresh(tx, attribute_names=["categories"]) # ensure categories are loaded + + return TransactionRead( + id=tx.id, + amount=float(tx.amount), + description=tx.description, + date=tx.date, + category_ids=[c.id for c in (tx.categories or [])], + ) diff --git a/7project/backend/app/app.py b/7project/backend/app/app.py index e692435..580d12d 100644 --- a/7project/backend/app/app.py +++ b/7project/backend/app/app.py @@ -68,6 +68,8 @@ fastApi.include_router(auth_router) fastApi.include_router(categories_router) fastApi.include_router(transactions_router) fastApi.include_router(exchange_rates_router) +from app.api.mock_bank import router as mock_bank_router +fastApi.include_router(mock_bank_router) for h in list(logging.root.handlers): logging.root.removeHandler(h) diff --git a/7project/frontend/src/pages/Dashboard.tsx b/7project/frontend/src/pages/Dashboard.tsx index 5252a20..d32a3f4 100644 --- a/7project/frontend/src/pages/Dashboard.tsx +++ b/7project/frontend/src/pages/Dashboard.tsx @@ -195,44 +195,50 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) { setIsGenerating(true); setMockModalOpen(false); - const { count, minAmount, maxAmount, startDate, endDate, categoryIds } = options; - const newTransactions: Transaction[] = []; - - const startDateTime = new Date(startDate).getTime(); - const endDateTime = new Date(endDate).getTime(); - - for (let i = 0; i < count; i++) { - // Generate random data based on user input - const amount = parseFloat((Math.random() * (maxAmount - minAmount) + minAmount).toFixed(2)); - - const randomTime = Math.random() * (endDateTime - startDateTime) + startDateTime; - const date = new Date(randomTime); - const dateString = date.toISOString().split('T')[0]; - - const randomCategory = categoryIds.length > 0 - ? [categoryIds[Math.floor(Math.random() * categoryIds.length)]] - : []; - - const payload = { - amount, - date: dateString, - category_ids: randomCategory, - }; - - try { - const created = await createTransaction(payload); - newTransactions.push(created); - } catch (err) { - console.error("Failed to create mock transaction:", err); - alert('An error occurred while generating transactions. Check the console.'); - break; + try { + const base = BACKEND_URL.replace(/\/$/, ''); + const url = `${base}/mock-bank/generate`; + const token = localStorage.getItem('token'); + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + credentials: 'include', + body: JSON.stringify(options), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || `Failed to generate mock transactions (${res.status})`); } + const generated: Array<{ amount: number; date: string; category_ids: number[]; description?: string | null }> + = await res.json(); + + const newTransactions: Transaction[] = []; + for (const g of generated) { + try { + const created = await createTransaction({ + amount: g.amount, + date: g.date, + category_ids: g.category_ids || [], + description: g.description || undefined, + }); + newTransactions.push(created); + } catch (err) { + console.error('Failed to create mock transaction:', err); + // continue creating others + } + } + + alert(`${newTransactions.length} mock transactions were successfully generated!`); + } catch (err: any) { + console.error(err); + alert(err?.message || 'Failed to generate mock transactions'); + } finally { + setIsGenerating(false); + await loadAll(); } - - setIsGenerating(false); - alert(`${newTransactions.length} mock transactions were successfully generated!`); - - await loadAll(); } useEffect(() => { loadAll(); }, [startDate, endDate]);