Compare commits

75 Commits

Author SHA1 Message Date
ribardej
f0c28ba9e1 feat(docs): codebase refactor - added src directory 2025-11-13 13:55:40 +01:00
ribardej
b560c07d62 feat(docs): codebase refactor - added src directory 2025-11-13 13:52:27 +01:00
ribardej
f0b1452e30 feat(docs): codebase refactor - added src directory 2025-11-13 13:45:41 +01:00
6effb2793a update report 2025-11-13 13:24:24 +01:00
ribardej
ba7798259c feat(docs): report.md update 2025-11-13 12:36:05 +01:00
deb67f421e Create README.md 2025-11-13 12:24:29 +01:00
74557eeea8 update report 2025-11-13 12:06:15 +01:00
2e0619d03f update report 2025-11-13 11:52:07 +01:00
31add42d6d update report 2025-11-13 11:13:11 +01:00
4de79169a2 update report 2025-11-13 11:11:16 +01:00
59d53967b0 update report
Some checks are pending
Deploy Prod / Run Python Tests (push) Waiting to run
Deploy Prod / Build and push image (reusable) (push) Blocked by required conditions
Deploy Prod / Generate Production URLs (push) Blocked by required conditions
Deploy Prod / Frontend - Build and Deploy to Cloudflare Pages (prod) (push) Blocked by required conditions
Deploy Prod / Helm upgrade/install (prod) (push) Blocked by required conditions
2025-11-13 01:35:13 +01:00
f3086f8c73 update report, edit deployment, update tfvars.example 2025-11-13 00:04:31 +01:00
ribardej
fd437b1caf feat(frontend): implemented CSAS button responsiveness 2025-11-12 20:21:31 +01:00
96ebc27001 updates
Some checks are pending
Deploy Prod / Run Python Tests (push) Waiting to run
Deploy Prod / Build and push image (reusable) (push) Blocked by required conditions
Deploy Prod / Generate Production URLs (push) Blocked by required conditions
Deploy Prod / Frontend - Build and Deploy to Cloudflare Pages (prod) (push) Blocked by required conditions
Deploy Prod / Helm upgrade/install (prod) (push) Blocked by required conditions
2025-11-12 17:34:50 +01:00
ribardej
922651fdbf fix(frontend): implemented CSAS button responsiveness 2025-11-12 15:37:53 +01:00
ribardej
e164b185e0 feat(frontend): implemented CSAS button responsiveness 2025-11-12 15:31:30 +01:00
ribardej
186b4fd09a fix(frontend): implemented multiple transaction selections in UI 2025-11-12 15:21:08 +01:00
ribardej
280d495335 feat(frontend): implemented multiple transaction selections in UI 2025-11-12 15:10:00 +01:00
ribardej
e73233c90a feat(docs): report.md update and refactored tests 2025-11-12 14:42:04 +01:00
ribardej
aade78bf3f feat(docs): report.md update and added options to test-with-ephemeral-mariadb.sh 2025-11-12 14:12:04 +01:00
ribardej
50e489a8e0 feat(tests): implemented local test DB container for isolation 2025-11-12 13:29:20 +01:00
ribardej
1679abb71f feat(tests): implemented local test DB container for isolation 2025-11-12 13:29:09 +01:00
573404dead feat(infrastructure): use correct url
Some checks are pending
Deploy Prod / Run Python Tests (push) Waiting to run
Deploy Prod / Build and push image (reusable) (push) Blocked by required conditions
Deploy Prod / Generate Production URLs (push) Blocked by required conditions
Deploy Prod / Frontend - Build and Deploy to Cloudflare Pages (prod) (push) Blocked by required conditions
Deploy Prod / Helm upgrade/install (prod) (push) Blocked by required conditions
2025-11-12 01:11:53 +01:00
d57dd82a64 feat(infrastructure): use correct url 2025-11-12 01:09:29 +01:00
50f37c1161 feat(infrastructure): use newer image 2025-11-12 00:58:54 +01:00
ae22d2ee5f feat(infrastructure): make tests mandatory 2025-11-12 00:46:36 +01:00
509608f8c9 Merge pull request #50 from dat515-2025/merge/update_workers
feat(workers): update workers
2025-11-12 00:42:16 +01:00
ed723d1d13 Update 7project/backend/app/workers/celery_tasks.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-12 00:42:02 +01:00
b0dee5e289 Update 7project/backend/app/services/bank_scraper.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-12 00:41:45 +01:00
640da2ee04 Update 7project/backend/app/services/bank_scraper.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-12 00:41:34 +01:00
ab9aefd140 feat(workers): update workers 2025-11-12 00:38:39 +01:00
ribardej
4eaf46e77e fix(backend): http redirect for exchange_rates.py fix 2025-11-11 21:00:59 +01:00
Dejan Ribarovski
a30ae4d010 Merge pull request #48 from dat515-2025/47-move-the-currency-api-and-mock-bank-to-backend
fix(tests): fixed test runtime errors regarding database connection
2025-11-11 20:15:15 +01:00
ribardej
ef26e88713 feat(backend): moved mock bank to backend 2025-11-11 18:47:35 +01:00
ribardej
2e1dddb4f8 fix(frontend): fixed dashboard error 2025-11-11 16:30:34 +01:00
ribardej
25e587cea8 fix(db): updated db setup for tests 2025-11-11 16:28:12 +01:00
ribardej
3cdefc33fc feat(backend): updated deploy-pr.yaml 2025-11-11 16:02:37 +01:00
ribardej
5954e56956 feat(backend): Moved the unirate API to the backend 2025-11-11 16:01:11 +01:00
Dejan Ribarovski
8575ef8ff5 Merge branch 'main' into 47-move-the-currency-api-and-mock-bank-to-backend 2025-11-11 15:39:08 +01:00
c53e314b2a fix(tests): set pytest env
Some checks are pending
Deploy Prod / Run Python Tests (push) Waiting to run
Deploy Prod / Build and push image (reusable) (push) Waiting to run
Deploy Prod / Generate Production URLs (push) Waiting to run
Deploy Prod / Frontend - Build and Deploy to Cloudflare Pages (prod) (push) Blocked by required conditions
Deploy Prod / Helm upgrade/install (prod) (push) Blocked by required conditions
2025-11-11 15:36:12 +01:00
c0bc44622f fix(tests): set pytest env 2025-11-11 15:34:11 +01:00
3d31ff4631 fix(tests): do not include prometheus in test env 2025-11-11 15:29:47 +01:00
ribardej
8b92b9bd18 fix(tests): fixed test runtime errors regarding database connection 2025-11-11 15:28:48 +01:00
ribardej
3d26ed6a62 fix(tests): fixed test runtime errors regarding database connection 2025-11-11 15:27:03 +01:00
ribardej
67b44539f2 fix(tests): fixed test runtime errors regarding database connection 2025-11-11 15:12:13 +01:00
ribardej
ff9cc712db fix(tests): fixed test runtime errors regarding database connection 2025-11-11 15:05:44 +01:00
dc7ce9e6a1 Merge pull request #49 from dat515-2025/merge/email_sender
feat(infrastructure): add email sender
2025-11-11 15:04:40 +01:00
188cdf5727 Update .github/workflows/deploy-prod.yaml
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-11 15:03:53 +01:00
4cf0d2a981 Update 7project/charts/myapp-chart/templates/prod.yaml
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-11 15:03:43 +01:00
9986cce8f9 Update 7project/charts/myapp-chart/values.yaml
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-11 15:03:36 +01:00
b3b5717e9e feat(infrastructure): add email sender 2025-11-11 14:59:28 +01:00
ribardej
1da927dc07 fix(tests): fixed test runtime errors regarding database connection 2025-11-11 14:50:43 +01:00
537d050080 feat(deployment): add 404 for public access 2025-11-11 14:16:08 +01:00
1e4f342176 feat(deployment): add cron support 2025-11-11 14:07:33 +01:00
c62e0adcf3 feat(deployment): add cron support 2025-11-11 14:03:31 +01:00
24d86abfc4 feat(deployment): add cron support 2025-11-11 13:58:36 +01:00
21305f18e2 feat(deployment): add cron support 2025-11-11 13:54:45 +01:00
e708f7b18b feat(deployment): add cron support 2025-11-11 13:52:17 +01:00
f58083870f Merge pull request #46 from dat515-2025/merge/prometheus_custom_metrics
Some checks failed
Deploy Prod / Run Python Tests (push) Has been cancelled
Deploy Prod / Build and push image (reusable) (push) Has been cancelled
Deploy Prod / Generate Production URLs (push) Has been cancelled
Deploy Prod / Frontend - Build and Deploy to Cloudflare Pages (prod) (push) Has been cancelled
Deploy Prod / Helm upgrade/install (prod) (push) Has been cancelled
feat(prometheus): add custom metrics
2025-11-09 12:54:52 +01:00
ca8287cd8b feat(prometheus): add custom metrics 2025-11-09 12:43:27 +01:00
ribardej
ed3e6329dd feat(docs): new metting.md
Some checks failed
Deploy Prod / Run Python Tests (push) Has been cancelled
Deploy Prod / Build and push image (reusable) (push) Has been cancelled
Deploy Prod / Generate Production URLs (push) Has been cancelled
Deploy Prod / Frontend - Build and Deploy to Cloudflare Pages (prod) (push) Has been cancelled
Deploy Prod / Helm upgrade/install (prod) (push) Has been cancelled
2025-11-06 13:13:16 +01:00
ribardej
a214e2cd8b fix(test): fixed tests for local usage and documentation in report.md 2025-11-06 12:28:42 +01:00
6c8d2202b5 update report 2025-11-06 12:03:15 +01:00
Dejan Ribarovski
b480734fee Merge pull request #45 from dat515-2025/add_more_tests
feat(test): added more tests
2025-11-06 11:31:07 +01:00
ribardej
8b301c386e feat(test): added more tests 2025-11-06 11:20:10 +01:00
ribardej
733e7a8918 feat(test): added more tests 2025-11-06 11:14:57 +01:00
ribardej
524e7a6f98 fix(frontend): fixed exchange rates and app name 2025-11-06 09:56:16 +01:00
ribardej
0c9882e9b3 feat(frontend): fixed exchange rates
Some checks are pending
Deploy Prod / Run Python Tests (push) Waiting to run
Deploy Prod / Build and push image (reusable) (push) Waiting to run
Deploy Prod / Generate Production URLs (push) Waiting to run
Deploy Prod / Frontend - Build and Deploy to Cloudflare Pages (prod) (push) Blocked by required conditions
Deploy Prod / Helm upgrade/install (prod) (push) Blocked by required conditions
2025-11-05 23:14:12 +01:00
Dejan Ribarovski
72494c4aae Merge pull request #44 from dat515-2025/43-fix-the-ui-layout-in-chrome
Fixed the layout issues for Chrome-based browsers, added options for users modifying transactions in the UI and implemented mobile friendly UI responsiveness
2025-11-05 20:42:38 +01:00
Dejan Ribarovski
60560dea99 Update 7project/frontend/src/pages/Dashboard.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-05 20:39:52 +01:00
ribardej
a9b2aba55a feat(frontend): implemented mobile friendly UI responsiveness 2025-11-05 20:24:33 +01:00
ribardej
36b1fe887b feat(frontend): Added options for modifying and deleting transactions in the UI 2025-11-05 18:00:24 +01:00
ribardej
8543c72730 fix(frontend): fixed the layout for chrome based browsers 2025-11-05 15:49:31 +01:00
24087c2810 updated report 2025-11-02 22:59:12 +01:00
ribardej
6818b1f649 fix(frontend): CNB API fix
Some checks failed
Deploy Prod / Run Python Tests (push) Has been cancelled
Deploy Prod / Build and push image (reusable) (push) Has been cancelled
Deploy Prod / Generate Production URLs (push) Has been cancelled
Deploy Prod / Frontend - Build and Deploy to Cloudflare Pages (prod) (push) Has been cancelled
Deploy Prod / Helm upgrade/install (prod) (push) Has been cancelled
2025-10-30 22:37:32 +01:00
176 changed files with 2538 additions and 1116 deletions

View File

@@ -15,7 +15,7 @@ on:
context:
description: "Docker build context path"
required: false
default: "7project/backend"
default: "7project/src/backend"
type: string
pr_number:
description: "PR number (required when mode=pr)"

View File

@@ -21,7 +21,7 @@ jobs:
with:
mode: pr
image_repo: lukastrkan/cc-app-demo
context: 7project/backend
context: 7project/src/backend
pr_number: ${{ github.event.pull_request.number }}
secrets: inherit
@@ -33,7 +33,7 @@ jobs:
runner: vhs
mode: pr
pr_number: ${{ github.event.pull_request.number }}
base_domain: ${{ vars.DEV_BASE_DOMAIN }}
base_domain: ${{ vars.PROD_DOMAIN }}
secrets: inherit
frontend:
@@ -77,7 +77,7 @@ jobs:
- name: Helm upgrade/install PR preview
env:
DEV_BASE_DOMAIN: ${{ secrets.BASE_DOMAIN }}
DEV_BASE_DOMAIN: ${{ vars.BASE_DOMAIN }}
RABBITMQ_PASSWORD: ${{ secrets.PROD_RABBITMQ_PASSWORD }}
DB_PASSWORD: ${{ secrets.PROD_DB_PASSWORD }}
DIGEST: ${{ needs.build.outputs.digest }}
@@ -85,13 +85,14 @@ 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
NAMESPACE=pr-$PR
helm upgrade --install "$RELEASE" ./7project/charts/myapp-chart \
helm upgrade --install "$RELEASE" ./7project/src/charts/myapp-chart \
-n "$NAMESPACE" --create-namespace \
-f 7project/charts/myapp-chart/values-dev.yaml \
-f 7project/src/charts/myapp-chart/values-dev.yaml \
--set prNumber="$PR" \
--set deployment="pr-$PR" \
--set domain="$DOMAIN" \
@@ -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

View File

@@ -4,9 +4,9 @@ on:
push:
branches: [ "main" ]
paths:
- 7project/backend/**
- 7project/frontend/**
- 7project/charts/myapp-chart/**
- ../../7project/src/backend/**
- ../../7project/src/frontend/**
- ../../7project/src/charts/myapp-chart/**
- .github/workflows/deploy-prod.yaml
- .github/workflows/build-image.yaml
- .github/workflows/frontend-pages.yml
@@ -27,15 +27,17 @@ jobs:
build:
name: Build and push image (reusable)
needs: [test]
uses: ./.github/workflows/build-image.yaml
with:
mode: prod
image_repo: lukastrkan/cc-app-demo
context: 7project/backend
context: 7project/src/backend
secrets: inherit
get_urls:
name: Generate Production URLs
needs: [test]
uses: ./.github/workflows/url_generator.yml
with:
mode: prod
@@ -92,10 +94,18 @@ jobs:
CSAS_CLIENT_ID: ${{ secrets.CSAS_CLIENT_ID }}
CSAS_CLIENT_SECRET: ${{ secrets.CSAS_CLIENT_SECRET }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SMTP_HOST: ${{ secrets.SMTP_HOST }}
SMTP_PORT: ${{ secrets.SMTP_PORT }}
SMTP_USERNAME: ${{ secrets.SMTP_USERNAME }}
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
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 \
helm upgrade --install myapp ./7project/src/charts/myapp-chart \
-n prod --create-namespace \
-f 7project/charts/myapp-chart/values-prod.yaml \
-f 7project/src/charts/myapp-chart/values-prod.yaml \
--set deployment="prod" \
--set domain="$DOMAIN" \
--set domain_scheme="$DOMAIN_SCHEME" \
@@ -111,4 +121,12 @@ jobs:
--set-string oauth.csas.clientId="$CSAS_CLIENT_ID" \
--set-string oauth.csas.clientSecret="$CSAS_CLIENT_SECRET" \
--set-string sentry_dsn="$SENTRY_DSN" \
--set-string database.encryptionSecret="${{ secrets.PROD_DB_ENCRYPTION_KEY }}"
--set-string database.encryptionSecret="${{ secrets.PROD_DB_ENCRYPTION_KEY }}" \
--set-string smtp.host="$SMTP_HOST" \
--set smtp.port="$SMTP_PORT" \
--set-string smtp.username="$SMTP_USERNAME" \
--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" \
--set-string unirate.key="$UNIRATE_API_KEY"

View File

@@ -35,7 +35,7 @@ jobs:
runs-on: ubuntu-latest
defaults:
run:
working-directory: 7project/frontend
working-directory: 7project/src/frontend
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -45,7 +45,7 @@ jobs:
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: 7project/frontend/package-lock.json
cache-dependency-path: 7project/src/frontend/package-lock.json
- name: Install dependencies
run: npm ci
@@ -61,7 +61,7 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: frontend-dist
path: 7project/frontend/dist
path: 7project/src/frontend/dist
deploy:
name: Deploy to Cloudflare Pages

View File

@@ -31,6 +31,9 @@ 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
DISABLE_METRICS: "1"
steps:
- name: Check out repository code
@@ -43,19 +46,21 @@ jobs:
- name: Add test dependencies to requirements
run: |
echo "pytest==8.4.2" >> ./7project/backend/requirements.txt
echo "pytest-asyncio==1.2.0" >> ./7project/backend/requirements.txt
echo "pytest==8.4.2" >> ./7project/src/backend/requirements.txt
echo "pytest-asyncio==1.2.0" >> ./7project/src/backend/requirements.txt
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r ./7project/backend/requirements.txt
pip install -r ./7project/src/backend/requirements.txt
- name: Run Alembic migrations
run: |
alembic upgrade head
working-directory: ./7project/backend
working-directory: ./7project/src/backend
- name: Run tests with pytest
env:
PYTEST_RUN_CONFIG: "True"
run: pytest
working-directory: ./7project/backend
working-directory: ./7project/src/backend

8
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

16
7project/.gitignore vendored
View File

@@ -1,8 +1,8 @@
/tofu/controlplane.yaml
/tofu/kubeconfig
/tofu/talosconfig
/tofu/terraform.tfstate
/tofu/terraform.tfstate.backup
/tofu/worker.yaml
/tofu/.terraform.lock.hcl
/tofu/.terraform/
/src/tofu/controlplane.yaml
/src/tofu/kubeconfig
/src/tofu/talosconfig
/src/tofu/terraform.tfstate
/src/tofu/terraform.tfstate.backup
/src/tofu/worker.yaml
/src/tofu/.terraform.lock.hcl
/src/tofu/.terraform/

8
7project/.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@@ -1,107 +0,0 @@
import logging
import asyncio
from celery import shared_task
import app.services.bank_scraper
logger = logging.getLogger("celery_tasks")
if not logger.handlers:
_h = logging.StreamHandler()
logger.addHandler(_h)
logger.setLevel(logging.INFO)
def run_coro(coro) -> None:
"""Run an async coroutine in a fresh event loop without using run_until_complete.
Primary strategy runs in a new loop in the current thread. If that fails due to
debugger patches (e.g., Bad file descriptor from pydevd_nest_asyncio), fall back
to running in a dedicated thread with its own event loop.
"""
import threading
def _cleanup_loop(loop):
try:
pending = [t for t in asyncio.all_tasks(loop) if not t.done()]
for t in pending:
t.cancel()
if pending:
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
except Exception:
pass
finally:
try:
loop.close()
finally:
asyncio.set_event_loop(None)
# First attempt: Run in current thread with a fresh event loop
try:
loop = asyncio.get_event_loop_policy().new_event_loop()
try:
asyncio.set_event_loop(loop)
task = loop.create_task(coro)
task.add_done_callback(lambda _t: loop.stop())
loop.run_forever()
exc = task.exception()
if exc:
raise exc
return
finally:
_cleanup_loop(loop)
except OSError as e:
logger.warning("run_coro primary strategy failed (%s). Falling back to thread runner.", e)
except Exception:
# For any other unexpected errors, try thread fallback as well
logger.exception("run_coro primary strategy raised; attempting thread fallback")
# Fallback: Run in a dedicated thread with its own event loop
error = {"exc": None}
def _thread_target():
loop = asyncio.new_event_loop()
try:
asyncio.set_event_loop(loop)
task = loop.create_task(coro)
task.add_done_callback(lambda _t: loop.stop())
loop.run_forever()
exc = task.exception()
if exc:
error["exc"] = exc
finally:
_cleanup_loop(loop)
th = threading.Thread(target=_thread_target, name="celery-async-runner", daemon=True)
th.start()
th.join()
if error["exc"] is not None:
raise error["exc"]
@shared_task(name="workers.send_email")
def send_email(to: str, subject: str, body: str) -> None:
if not (to and subject and body):
logger.error("Email task missing fields. to=%r subject=%r body_len=%r", to, subject, len(body) if body else 0)
return
# Placeholder for real email sending logic
logger.info("[Celery] Email sent | to=%s | subject=%s | body_len=%d", to, subject, len(body))
@shared_task(name="workers.load_transactions")
def load_transactions(user_id: str) -> None:
if not user_id:
logger.error("Load transactions task missing user_id.")
return
run_coro(app.services.bank_scraper.aload_ceska_sporitelna_transactions(user_id))
# Placeholder for real transaction loading logic
logger.info("[Celery] Transactions loaded for user_id=%s", user_id)
@shared_task(name="workers.load_all_transactions")
def load_all_transactions() -> None:
logger.info("[Celery] Starting load_all_transactions")
run_coro(app.services.bank_scraper.aload_all_ceska_sporitelna_transactions())
logger.info("[Celery] Finished load_all_transactions")

View File

@@ -1,98 +0,0 @@
import pytest
import uuid
from httpx import AsyncClient, ASGITransport
from fastapi import status
def test_e2e_minimal_auth_flow(client):
# 1) Service is alive
alive = client.get("/")
assert alive.status_code == status.HTTP_200_OK
# 2) Attempt to login without payload should fail fast (validation error)
login = client.post("/auth/jwt/login")
assert login.status_code in (status.HTTP_400_BAD_REQUEST, status.HTTP_422_UNPROCESSABLE_CONTENT)
# 3) Protected endpoint should not be accessible without token
me = client.get("/users/me")
assert me.status_code in (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN)
@pytest.mark.asyncio
async def test_e2e_full_user_lifecycle(fastapi_app, test_user):
# Use an AsyncClient with ASGITransport for async tests
transport = ASGITransport(app=fastapi_app, raise_app_exceptions=True)
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
login_payload = test_user
# 1. Log in with the new credentials
login_resp = await ac.post("/auth/jwt/login", data=login_payload)
assert login_resp.status_code == status.HTTP_200_OK
token = login_resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
# 2. Access a protected endpoint
me_resp = await ac.get("/users/me", headers=headers)
assert me_resp.status_code == status.HTTP_200_OK
assert me_resp.json()["email"] == test_user["username"]
# 3. Update the user's profile
update_payload = {"first_name": "Test"}
patch_resp = await ac.patch("/users/me", json=update_payload, headers=headers)
assert patch_resp.status_code == status.HTTP_200_OK
assert patch_resp.json()["first_name"] == "Test"
# 4. Log out
logout_resp = await ac.post("/auth/jwt/logout", headers=headers)
assert logout_resp.status_code in (status.HTTP_200_OK, status.HTTP_204_NO_CONTENT)
# 5. Verify token is invalid
me_again_resp = await ac.get("/users/me", headers=headers)
assert me_again_resp.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.asyncio
async def test_e2e_transaction_workflow(fastapi_app, test_user):
transport = ASGITransport(app=fastapi_app, raise_app_exceptions=True)
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
# 1. Log in to get the token
login_resp = await ac.post("/auth/jwt/login", data=test_user)
token = login_resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
# NEW STEP: Create a category first to get a valid ID
category_payload = {"name": "Test Category for E2E"}
create_category_resp = await ac.post("/categories/create", json=category_payload, headers=headers)
assert create_category_resp.status_code == status.HTTP_201_CREATED
category_id = create_category_resp.json()["id"]
# 2. Create a new transaction
tx_payload = {"amount": -55.40, "description": "Milk and eggs"}
tx_resp = await ac.post("/transactions/create", json=tx_payload, headers=headers)
assert tx_resp.status_code == status.HTTP_201_CREATED
tx_id = tx_resp.json()["id"]
# 3. Assign the category
assign_resp = await ac.post(f"/transactions/{tx_id}/categories/{category_id}", headers=headers)
assert assign_resp.status_code == status.HTTP_200_OK
# 4. Verify assignment
get_tx_resp = await ac.get(f"/transactions/{tx_id}", headers=headers)
assert category_id in get_tx_resp.json()["category_ids"]
# 5. Unassign the category
unassign_resp = await ac.delete(f"/transactions/{tx_id}/categories/{category_id}", headers=headers)
assert unassign_resp.status_code == status.HTTP_200_OK
# 6. Get the transaction again and verify the category is gone
get_tx_again_resp = await ac.get(f"/transactions/{tx_id}", headers=headers)
final_tx_data = get_tx_again_resp.json()
assert category_id not in final_tx_data["category_ids"]
# 7. Delete the transaction for cleanup
delete_resp = await ac.delete(f"/transactions/{tx_id}/delete", headers=headers)
assert delete_resp.status_code in (status.HTTP_200_OK, status.HTTP_204_NO_CONTENT)
# NEW STEP: Clean up the created category
delete_category_resp = await ac.delete(f"/categories/{category_id}", headers=headers)
assert delete_category_resp.status_code in (status.HTTP_200_OK, status.HTTP_204_NO_CONTENT)

View File

@@ -1,66 +0,0 @@
from fastapi import status
import pytest
from httpx import AsyncClient, ASGITransport
def test_root_ok(client):
resp = client.get("/")
assert resp.status_code == status.HTTP_200_OK
assert resp.json() == {"status": "ok"}
def test_authenticated_route_requires_auth(client):
resp = client.get("/authenticated-route")
assert resp.status_code in (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN)
@pytest.mark.asyncio
async def test_create_and_get_category(fastapi_app, test_user):
# Use AsyncClient for async tests
transport = ASGITransport(app=fastapi_app)
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
# 1. Log in to get an auth token
login_resp = await ac.post("/auth/jwt/login", data=test_user)
token = login_resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
# 2. Define and create the new category
category_name = "Async Integration Test"
category_payload = {"name": category_name}
create_resp = await ac.post("/categories/create", json=category_payload, headers=headers)
# 3. Assert creation was successful
assert create_resp.status_code == status.HTTP_201_CREATED
created_data = create_resp.json()
category_id = created_data["id"]
assert created_data["name"] == category_name
# 4. GET the list of categories to verify
list_resp = await ac.get("/categories/", headers=headers)
assert list_resp.status_code == status.HTTP_200_OK
# 5. Check that our new category is in the list
categories_list = list_resp.json()
assert any(cat["name"] == category_name for cat in categories_list)
delete_resp = await ac.delete(f"/categories/{category_id}", headers=headers)
assert delete_resp.status_code in (status.HTTP_200_OK, status.HTTP_204_NO_CONTENT)
@pytest.mark.asyncio
async def test_create_transaction_missing_amount_fails(fastapi_app, test_user):
transport = ASGITransport(app=fastapi_app)
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
# 1. Log in to get an auth token
login_resp = await ac.post("/auth/jwt/login", data=test_user)
token = login_resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
# 2. Define an invalid payload
invalid_payload = {"description": "This should fail"}
# 3. Attempt to create the transaction
resp = await ac.post("/transactions/create", json=invalid_payload, headers=headers)
# 4. Assert the expected validation error
assert resp.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY

View File

@@ -1,2 +0,0 @@
export const BACKEND_URL: string =
import.meta.env.VITE_BACKEND_URL ?? '';

View File

@@ -1,46 +0,0 @@
// src/BalanceChart.tsx
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { type BalancePoint } from '../api';
function formatAmount(n: number) {
return new Intl.NumberFormat(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n);
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
export default function BalanceChart({ data }: { data: BalancePoint[] }) {
if (data.length === 0) {
return <div>No data to display</div>;
}
return (
<ResponsiveContainer width="100%" height={300}>
<LineChart
data={data}
// Increased 'left' margin to create more space for the Y-axis label and tick values
margin={{ top: 5, right: 30, left: 50, bottom: 5 }} // <-- Change this line
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="date"
tickFormatter={formatDate}
label={{ value: 'Date', position: 'insideBottom', offset: -5 }}
/>
<YAxis
tickFormatter={(value) => formatAmount(value as number)}
// Adjusted 'offset' for the Y-axis label.
// A negative offset moves it further away from the axis.
label={{ value: 'Balance', angle: -90, position: 'insideLeft', offset: -30 }} // <-- Change this line
/>
<Tooltip
labelFormatter={formatDate}
formatter={(value) => [formatAmount(value as number), 'Balance']}
/>
<Legend />
<Line type="monotone" dataKey="balance" stroke="#3b82f6" strokeWidth={2} activeDot={{ r: 8 }} />
</LineChart>
</ResponsiveContainer>
);
}

View File

@@ -1,452 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { type Category, type Transaction, type BalancePoint, getCategories, getTransactions, createTransaction, updateTransaction, getBalanceSeries } from '../api';
import AccountPage from './AccountPage';
import AppearancePage from './AppearancePage';
import BalanceChart from './BalanceChart';
import ManualManagement from './ManualManagement';
import CategoryPieChart from './CategoryPieChart';
import MockBankModal, { type MockGenerationOptions } from './MockBankModal';
import { BACKEND_URL } from '../config';
function formatAmount(n: number) {
return new Intl.NumberFormat(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n);
}
// Add this new component to your Dashboard.tsx file, above the Dashboard component
// Define the structure for the rate data we care about
type CnbRate = {
currencyCode: string;
rate: number;
};
// The part of the API response structure we need
type CnbApiResponse = {
rates: Array<{
amount: number;
currencyCode: string;
rate: number;
}>;
};
// The currencies you want to display
const TARGET_CURRENCIES = ['EUR', 'USD', 'NOK'];
function CurrencyRates() {
const [rates, setRates] = useState<CnbRate[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function fetchRates() {
setLoading(true);
setError(null);
// Get today's date in YYYY-MM-DD format for the API
const today = new Date().toISOString().split('T')[0];
const CNB_API_URL = `/api-cnb/cnbapi/exrates/daily?date=${today}&lang=EN`;
try {
const res = await fetch(CNB_API_URL);
if (!res.ok) {
// This can happen on weekends/holidays or if rates aren't posted yet
throw new Error(`Rates unavailable (Status: ${res.status})`);
}
const data: CnbApiResponse = await res.json();
if (!data.rates) {
throw new Error("Invalid API response");
}
const filteredRates = data.rates
.filter(rate => TARGET_CURRENCIES.includes(rate.currencyCode))
.map(rate => ({
currencyCode: rate.currencyCode,
// Handle 'amount' field (e.g., JPY is per 100)
rate: rate.rate / rate.amount
}));
setRates(filteredRates);
} catch (err: any) {
setError(err.message || 'Could not load rates');
} finally {
setLoading(false);
}
}
fetchRates();
}, []); // Runs once on component mount
return (
// This component will push itself to the bottom of the sidebar
<div
className="currency-rates"
style={{
padding: '0 1.5rem',
marginTop: 'auto', // Pushes to bottom
paddingBottom: '1.5rem' // Adds some spacing at the end
}}
>
<h4 style={{
margin: '1.5rem 0 0.75rem 0',
color: '#8a91b4', // Muted color to match dark sidebar
fontWeight: 500,
fontSize: '0.9em',
textTransform: 'uppercase',
}}>
Rates (vs CZK)
</h4>
{loading && <div style={{ fontSize: '0.9em', color: '#ccc' }}>Loading...</div>}
{error && <div style={{ fontSize: '0.9em', color: 'crimson' }}>{error}</div>}
{!loading && !error && (
<ul style={{ listStyle: 'none', padding: 0, margin: 0, fontSize: '0.9em', color: '#fff' }}>
{rates.length > 0 ? rates.map(rate => (
<li key={rate.currencyCode} style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
<strong>{rate.currencyCode}</strong>
<span>{rate.rate.toFixed(3)}</span>
</li>
)) : <li style={{color: '#8a91b4'}}>No rates found.</li>}
</ul>
)}
</div>
);
}
export default function Dashboard({ onLogout }: { onLogout: () => void }) {
const [current, setCurrent] = useState<'home' | 'manual' | 'account' | 'appearance'>('home');
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isMockModalOpen, setMockModalOpen] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
// Start CSAS (George) OAuth after login
async function startOauthCsas() {
const base = BACKEND_URL.replace(/\/$/, '');
const url = `${base}/auth/csas/authorize`;
try {
const token = localStorage.getItem('token');
const res = await fetch(url, {
credentials: 'include',
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
const data = await res.json();
if (data && typeof data.authorization_url === 'string') {
window.location.assign(data.authorization_url);
} else {
alert('Cannot start CSAS OAuth.');
}
} catch (e) {
alert('Cannot start CSAS OAuth.');
}
}
// Filters
const [minAmount, setMinAmount] = useState<string>('');
const [maxAmount, setMaxAmount] = useState<string>('');
const [filterCategoryId, setFilterCategoryId] = useState<number | ''>('');
const [searchText, setSearchText] = useState('');
// Date-range filter
const [startDate, setStartDate] = useState<string>(''); // YYYY-MM-DD
const [endDate, setEndDate] = useState<string>('');
// Pagination over filtered transactions (20 per page), 0 = latest (most recent)
const pageSize = 20;
const [page, setPage] = useState<number>(0);
// Balance chart series for current date filter
const [balanceSeries, setBalanceSeries] = useState<BalancePoint[]>([]);
// Manual forms moved to ManualManagement page
// Inline edit state for transaction categories
const [editingTxId, setEditingTxId] = useState<number | null>(null);
const [editingCategoryIds, setEditingCategoryIds] = useState<number[]>([]);
async function loadAll() {
setLoading(true);
setError(null);
try {
const [txs, cats, series] = await Promise.all([
getTransactions(startDate || undefined, endDate || undefined),
getCategories(),
getBalanceSeries(startDate || undefined, endDate || undefined),
]);
setTransactions(txs);
setCategories(cats);
setBalanceSeries(series);
// reset paging to most recent
setPage(0);
} catch (err: any) {
setError(err?.message || 'Failed to load data');
} finally {
setLoading(false);
}
}
async function handleGenerateMockTransactions(options: MockGenerationOptions) {
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;
}
}
setIsGenerating(false);
alert(`${newTransactions.length} mock transactions were successfully generated!`);
await loadAll();
}
useEffect(() => { loadAll(); }, [startDate, endDate]);
const filtered = useMemo(() => {
let arr = [...transactions];
const min = minAmount !== '' ? Number(minAmount) : undefined;
const max = maxAmount !== '' ? Number(maxAmount) : undefined;
if (min !== undefined) arr = arr.filter(t => t.amount >= min);
if (max !== undefined) arr = arr.filter(t => t.amount <= max);
if (filterCategoryId !== '') arr = arr.filter(t => t.category_ids.includes(filterCategoryId as number));
if (searchText.trim()) arr = arr.filter(t => (t.description || '').toLowerCase().includes(searchText.toLowerCase()));
return arr;
}, [transactions, minAmount, maxAmount, filterCategoryId, searchText]);
const sortedDesc = useMemo(() => {
return [...filtered].sort((a, b) => {
const ad = (a.date || '') > (b.date || '') ? 1 : (a.date || '') < (b.date || '') ? -1 : 0;
if (ad !== 0) return -ad; // date desc
return b.id - a.id; // fallback id desc
});
}, [filtered]);
const totalPages = Math.ceil(sortedDesc.length / pageSize);
const pageStart = page * pageSize;
const pageEnd = pageStart + pageSize;
const visible = sortedDesc.slice(pageStart, pageEnd);
function categoryNameById(id: number) { return categories.find(c => c.id === id)?.name || `#${id}`; }
function beginEditCategories(t: Transaction) {
setEditingTxId(t.id);
setEditingCategoryIds([...(t.category_ids || [])]);
}
function cancelEditCategories() {
setEditingTxId(null);
setEditingCategoryIds([]);
}
async function saveEditCategories() {
if (editingTxId == null) return;
try {
const updated = await updateTransaction(editingTxId, { category_ids: editingCategoryIds });
setTransactions(prev => prev.map(p => (p.id === updated.id ? updated : p)));
cancelEditCategories();
} catch (err: any) {
alert(err?.message || 'Failed to update transaction categories');
}
}
return (
<div className="app-layout">
<aside className="sidebar" style={{ display: 'flex', flexDirection: 'column' }}>
<div>
<div className="logo">7Project</div>
<nav className="nav">
<button className={current === 'home' ? 'active' : ''} onClick={() => setCurrent('home')}>Home</button>
<button className={current === 'manual' ? 'active' : ''} onClick={() => setCurrent('manual')}>Manual management</button>
<button className={current === 'account' ? 'active' : ''} onClick={() => setCurrent('account')}>Account</button>
<button className={current === 'appearance' ? 'active' : ''} onClick={() => setCurrent('appearance')}>Appearance</button>
</nav>
</div>
<CurrencyRates />
</aside>
<div className="content">
<div className="topbar">
<h2 style={{ margin: 0 }}>{current === 'home' ? 'Dashboard' : current === 'manual' ? 'Manual management' : current === 'account' ? 'Account' : 'Appearance'}</h2>
<div className="actions">
<span className="user muted">Signed in</span>
<button className="btn" onClick={onLogout}>Logout</button>
</div>
</div>
<main className="page space-y">
{current === 'home' && (
<>
<section className="card space-y">
<h3>Bank connections</h3>
<div className="connection-row">
<p className="muted" style={{ margin: 0 }}>Connect your CSAS (George) account.</p>
<button className="btn primary" onClick={startOauthCsas}>Connect CSAS (George)</button>
</div>
<div className="connection-row">
<p className="muted" style={{ margin: 0 }}>Generate data from a mock bank.</p>
<button className="btn primary" onClick={() => setMockModalOpen(true)}>Connect Mock Bank</button>
</div>
</section>
<section className="card">
<h3>Filters</h3>
<div className="form-row" style={{ gap: 8, flexWrap: 'wrap' }}>
<input className="input" type="date" placeholder="Start date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
<input className="input" type="date" placeholder="End date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
<input className="input" type="number" step="0.01" placeholder="Min amount" value={minAmount} onChange={(e) => setMinAmount(e.target.value)} />
<input className="input" type="number" step="0.01" placeholder="Max amount" value={maxAmount} onChange={(e) => setMaxAmount(e.target.value)} />
<select className="input" value={filterCategoryId} onChange={(e) => setFilterCategoryId(e.target.value ? Number(e.target.value) : '')}>
<option value="">All categories</option>
{categories.map(c => (<option key={c.id} value={c.id}>{c.name}</option>))}
</select>
<input className="input" type="text" placeholder="Search in description" value={searchText} onChange={(e) => setSearchText(e.target.value)} />
</div>
</section>
<section className="card">
<h3>Balance over time</h3>
{loading ? (
<div>Loading</div>
) : error ? (
<div style={{ color: 'crimson' }}>{error}</div>
) : (
<BalanceChart data={balanceSeries} />
)}
</section>
{/* 3. Add the new section for the Category Pie Chart */}
<section className="card">
{loading ? (
<div>Loading</div>
) : error ? (
<div style={{ color: 'crimson' }}>{error}</div>
) : (
// Pass the filtered transactions to see the breakdown for the current view
<CategoryPieChart transactions={filtered} categories={categories} />
)}
</section>
<section className="card">
<h3>Transactions</h3>
{loading ? (
<div>Loading</div>
) : error ? (
<div style={{ color: 'crimson' }}>{error}</div>
) : filtered.length === 0 ? (
<div>No transactions</div>
) : (
<>
<div className="table-controls">
<div className="muted">
Showing {visible.length} of {filtered.length} (page {Math.min(page + 1, Math.max(1, totalPages))}/{Math.max(1, totalPages)})
</div>
<div className="actions">
<button className="btn primary" disabled={page <= 0} onClick={() => setPage(p => Math.max(0, p - 1))}>Previous</button>
<button className="btn primary" disabled={page >= totalPages - 1} onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}>Next</button>
</div>
</div>
<table className="table">
<thead>
<tr>
<th>Date</th>
<th style={{ textAlign: 'right' }}>Amount</th>
<th>Description</th>
<th>Categories</th>
</tr>
</thead>
<tbody>
{visible.map(t => (
<tr key={t.id}>
<td>{t.date || ''}</td>
<td className="amount">{formatAmount(t.amount)}</td>
<td>{t.description || ''}</td>
<td>
{editingTxId === t.id ? (
<div className="space-y" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<select multiple className="input" value={editingCategoryIds.map(String)} onChange={(e) => {
const opts = Array.from(e.currentTarget.selectedOptions).map(o => Number(o.value));
setEditingCategoryIds(opts);
}}>
{categories.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
<button className="btn small" onClick={saveEditCategories}>Save</button>
<button className="btn small" onClick={cancelEditCategories}>Cancel</button>
</div>
) : (
<div className="space-x" style={{ display: 'flex', alignItems: 'center', gap: 8, justifyContent: 'space-between' }}>
<span>{t.category_ids.map(id => categoryNameById(id)).join(', ') || '—'}</span>
<button className="btn small" onClick={() => beginEditCategories(t)}>Change</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</>
)}
</section>
</>
)}
{current === 'account' && (
// lazy import avoided for simplicity
<AccountPage onDeleted={onLogout} />
)}
{current === 'manual' && (
<ManualManagement
categories={categories}
onTransactionAdded={(t) => setTransactions(prev => [t, ...prev])}
onCategoryCreated={(c) => setCategories(prev => [...prev, c])}
/>
)}
{current === 'appearance' && (
<AppearancePage />
)}
</main>
</div>
<MockBankModal
isOpen={isMockModalOpen}
isGenerating={isGenerating}
categories={categories}
onClose={() => setMockModalOpen(false)}
onGenerate={handleGenerateMockTransactions}
/>
</div>
);
}

View File

@@ -43,8 +43,8 @@ The tracker should not store the transactions in the database - security vulnera
Last 3 minutes of the meeting, summarize action items.
- [ ] Change the name on frontend from 7project
- [ ] Finalize the funcionality and everyting in the code part
- [x] Change the name on frontend from 7project
- [x] Finalize the funcionality and everyting in the code part
- [ ] Try to finalize report with focus on reproducibility
- [ ] More high level explanation of the workflow in the report

View File

@@ -0,0 +1,47 @@
# Weekly Meeting Notes
- Group 8 - Personal finance tracker
- Mentor: Jaychander
Keep all meeting notes in the `meetings.md` file in your project folder.
Just copy the template below for each weekly meeting and fill in the details.
## Administrative Info
- Date: 2025-10-30
- Attendees: Dejan, Lukas
- Notetaker: Dejan
## Progress Update (Before Meeting)
Last 3 minutes of the meeting, summarize action items.
- [x] Change the name on frontend from 7project
- [x] Finalize the funcionality and everyting in the code part
- [x] Try to finalize report with focus on reproducibility
- [x] More high level explanation of the workflow in the report
Summary of what has been accomplished since the last meeting in the following categories.
### Coding
### Documentation
## Questions and Topics for Discussion (Before Meeting)
## Discussion Notes (During Meeting)
The tracker should not store the transactions in the database - security vulnerability.
## Action Items for Next Week (During Meeting)
Last 3 minutes of the meeting, summarize action items.
- [ ] video
- [ ] highlight the optional stuff in the report
---

View File

@@ -1,9 +1,9 @@
# Personal finance tracker
> **Instructions**:
<!--- **Instructions**:
> This template provides the structure for your project report.
> Replace the placeholder text with your actual content.
> Remove instructions that are not relevant for your project, but leave the headings along with a (NA) label.
> Remove instructions that are not relevant for your project, but leave the headings along with a (NA) label. -->
## Project Overview
@@ -12,231 +12,446 @@
**Group Members**:
- 289229, Lukáš Trkan, lukastrkan
- 289258, Dejan Ribarovski, derib2613, ribardej
- 289258, Dejan Ribarovski, ribardej (derib2613)
**Brief Description**: (něco spíš jako abstract, introuction, story behind)
Our application is a finance tracker, so a person can easily track his cash flow
through multiple bank accounts. Person can label transactions with custom categories
and later filter by them.
**Brief Description**:
Our application allows users to easily track their cash flow
through multiple bank accounts. Users can label their transactions with custom categories that can be later used for
filtering and visualization. New transactions are automatically fetched in the background.
## Architecture Overview
Our system is a fullstack web application composed of a React frontend, a FastAPI backend, a PostgreSQL database, and asynchronous background workers powered by Celery with RabbitMQ. Redis is available for caching/kv and may be used by Celery as a result backend. The backend exposes REST endpoints for authentication (email/password and OAuth), users, categories, and transactions. A thin controller layer (FastAPI routers) lives under app/api. Infrastructure for Kubernetes is provided via OpenTofu (Terraformcompatible) modules and the application is packaged via a Helm chart.
Our system is a fullstack web application composed of a React frontend, a FastAPI backend,
a asynchronousMariaDB database with Maxscale, and background workers powered by Celery with RabbitMQ.
The backend exposes REST endpoints for authentication (email/password and OAuth), users, categories,
transactions, exchange rates and bank APIs. Infrastructure for Kubernetes is managed via Terraform/OpenTofu and
the application is packaged via a Helm chart. This all is deployed on private TalosOS cluster running on Proxmox VE with
CI/CD and with public access over Cloudflare tunnels. Static files for frontend are served via Cloudflare pages.
Other services deployed in the cluster includes Longhorn for persistent storage, Prometheus with Grafana for monitoring.
### High-Level Architecture
```mermaid
flowchart LR
proc_queue[Message Queue] --> proc_queue_worker[Worker Service]
proc_queue_worker --> ext_mail[(Email Service)]
proc_cron[Task planner] --> proc_queue
proc_queue_worker --> ext_bank[(Bank API)]
proc_queue_worker --> db
client[Client/Frontend] <--> svc[Backend API]
flowchart TB
n3(("User")) <--> client["Frontend"]
proc_queue["Message Queue"] --> proc_queue_worker["Worker Service"]
proc_queue_worker -- SMTP --> ext_mail[("Email Service")]
proc_queue_worker <-- HTTP request/response --> ext_bank[("Bank API")]
proc_queue_worker <--> db[("Database")]
proc_cron["Cron"] <-- HTTP request/response --> svc["Backend API"]
svc --> proc_queue
svc <--> db[(Database)]
svc <--> cache[(Cache)]
n2["Cloudflare tunnel"] <-- HTTP request/response --> svc
svc <--> db
svc <-- HTTP request/response --> api[("UniRate API")]
client <-- HTTP request/response --> n2
```
The workflow works in the following way:
- Client connects to the frontend. After login, frontend automatically fetches the stored transactions from
the database via the backend API and currency rates from UniRate API.
- When the client opts for fetching new transactions via the Bank API, cron will trigger periodic fetching
using background worker.
- After successful load, these transactions are stored to the database and displayed to the client
### Features
- The stored transactions are encrypted in the DB for security reasons.
- For every pull request the full APP is deployed on a separate URL and the tests are run by github CI/CD
- On every push to main, the production app is automatically updated
- UI is responsive for mobile devices
- Slow operations (emails, transactions fetching) are handled
in the background by Celery workers.
- App is monitored using prometheus metrics endpoint and metrics are shown in Grafana dashboard.
### Components
- Frontend (frontend/): React + TypeScript app built with Vite. Talks to the backend via REST, handles login/registration, shows latest transactions, filtering, and allows adding transactions.
- Backend API (backend/app): FastAPI app with routers under app/api for auth, categories, and transactions. Uses FastAPI Users for auth (JWT + OAuth), SQLAlchemy ORM, and Pydantic v2 schemas.
- Worker service (backend/app/workers): Celery worker handling asynchronous tasks (e.g., sending verification emails, future background processing).
- Database (PostgreSQL): Persists users, categories, transactions; schema managed by Alembic migrations.
- Message Queue (RabbitMQ): Transports background jobs from the API to the worker.
- Cache/Result Store (Redis): Available for caching or Celery result backend.
- Infrastructure as Code (tofu/): OpenTofu modules provisioning cluster services (RabbitMQ, Redis, Argo CD, cert-manager, Cloudflare tunnel, etc.).
- Frontend (frontend/): React + TypeScript app built with Vite. Talks to the backend via REST, handles
login/registration, shows latest transactions, filtering, and allows adding transactions.
- Backend API (backend/app): FastAPI app with routers under app/api for auth, users, categories, transactions, exchange
rates and bankAPI. Uses FastAPI Users for auth (JWT + OAuth), SQLAlchemy ORM, and Pydantic v2 schemas.
- Worker service (backend/app/workers): Celery worker handling background tasks (emails, transactions fetching).
- Database (MariaDB with Maxscale): Persists users, categories, transactions; schema managed by Alembic migrations.
- Message Queue (RabbitMQ): Queues background tasks for Celery workers.
- Infrastructure as Code (tofu/): OpenTofu modules provisioning cluster services (RabbitMQ, Redis, Cloudflare tunnel,
etc.).
- Deployment Chart (charts/myapp-chart/): Helm chart to deploy the application to Kubernetes.
### Technologies Used
- Backend: Python, FastAPI, FastAPI Users, SQLAlchemy, Pydantic, Alembic, Celery
- Frontend: React, TypeScript, Vite
- Database: PostgreSQL
- Messaging: RabbitMQ
- Cache: Redis
- Database: MariaDB with Maxscale
- Background jobs: RabbitMQ, Celery
- Containerization/Orchestration: Docker, Docker Compose (dev), Kubernetes, Helm
- IaC/Platform: OpenTofu (Terraform), Argo CD, cert-manager, MetalLB, Cloudflare Tunnel, Prometheus
- IaC/Platform: Proxmox, Talos, Cloudflare pages, OpenTofu (Terraform), cert-manager, MetalLB, Cloudflare Tunnel,
Prometheus, Loki
## Prerequisites
### System Requirements
- Operating System: Linux, macOS, or Windows
- Minimum RAM: 4 GB (8 GB recommended for running backend, frontend, and database together)
- Storage: 2 GB free (Docker images may require additional space)
#### Development
- Minimum RAM: 8 GB
- Storage: 10 GB+ free
#### Production
- 1 + 4 nodes
- CPU: 4 cores
- RAM: 8 GB
- Storage: 200 GB
### Required Software
- Docker Desktop or Docker Engine 24+
- Docker Compose v2+
- Node.js 20+ and npm 10+ (for local frontend dev/build)
- Python 3.12+ (for local backend dev outside Docker)
- PostgreSQL 15+ (optional if running DB outside Docker)
- Helm 3.12+ and kubectl 1.29+ (for Kubernetes deployment)
- OpenTofu 1.7+ (for infrastructure provisioning)
#### Development
### Environment Variables (common)
- Docker
- Docker Compose
- Node.js and npm
- Python 3.12
- MariaDB 11
- Backend: SECRET, FRONTEND_URL, BACKEND_URL, DATABASE_URL, RABBITMQ_URL, REDIS_URL
- OAuth vars (Backend): MOJEID_CLIENT_ID/SECRET, BANKID_CLIENT_ID/SECRET (optional)
- Frontend: VITE_BACKEND_URL
#### Production
##### Minimal:
- domain name with Cloudflare`s nameservers - tunnel, pages
- Kubernetes cluster
- kubectl
- Helm
- OpenTofu
##### Our setup specifics:
- Proxmox VE
- TalosOS cluster
- talosctl
- GitHub self-hosted runner with access to the cluster
- TailScale for remote access to cluster
### Environment Variables
#### Backend
- `MOJEID_CLIENT_ID`, `MOJEID_CLIENT_SECRET` \- OAuth client ID and secret for
MojeID - https://www.mojeid.cz/en/provider/
- `BANKID_CLIENT_ID`, `BANKID_CLIENT_SECRET` \- OAuth client ID and secret for BankID - https://developer.bankid.cz/
- `CSAS_CLIENT_ID`, `CSAS_CLIENT_SECRET` \- OAuth client ID and secret for Česká
spořitelna - https://developers.erstegroup.com/docs/apis/bank.csas
- `DATABASE_URL`(or `MARIADB_HOST`, `MARIADB_PORT`, `MARIADB_DB`, `MARIADB_USER`, `MARIADB_PASSWORD`) \- MariaDB
connection details
- `RABBITMQ_USERNAME`, `RABBITMQ_PASSWORD` \- credentials for RabbitMQ
- `SENTRY_DSN` \- Sentry DSN for error reporting
- `DB_ENCRYPTION_KEY` \- symmetric key for encrypting sensitive data in the database
- `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_USE_TLS`, `SMTP_USE_SSL`, `SMTP_FROM` \- SMTP
configuration (host, port, auth credentials, TLS/SSL options, sender).
- `UNIRATE_API_KEY` \- API key for UniRate.
#### Frontend
- `VITE_BACKEND_URL` \- URL of the backend API
### Dependencies (key libraries)
I am not sure what is meant by "key libraries"
Backend: FastAPI, fastapi-users, SQLAlchemy, pydantic v2, Alembic, Celery
Frontend: React, TypeScript, Vite
Services: PostgreSQL, RabbitMQ, Redis
Backend: FastAPI, fastapi-users, SQLAlchemy, pydantic v2, Alembic, Celery, uvicorn, pytest
Frontend: React, TypeScript, Vite
## Build Instructions
## Local development
You can run the project with Docker Compose (recommended for local development) or run services manually.
You can run the project with Docker Compose and Python virtual environment for testing and development purposes
### 1) Clone the Repository
```bash
git clone https://github.com/dat515-2025/Group-8.git
cd 7project
cd Group-8/7project/src
```
### 2) Install dependencies
Backend
```bash
# In 7project/backend
python3.12 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
cd backend
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```
Frontend
### 3) Run Docker containers
```bash
# In 7project/frontend
npm install
cd ..
docker compose up -d
```
### 3) Manual Local Run
### 4) Prepare the database
Backend
```bash
# From the 7project/ directory
docker compose up --build
# This starts: PostgreSQL, RabbitMQ/Redis (if defined)
bash upgrade_database.sh
```
# Set environment variables (or create .env file)
export SECRET=CHANGE_ME_SECRET
export BACKEND_URL=http://127.0.0.1:8000
export FRONTEND_URL=http://localhost:5173
export DATABASE_URL=postgresql+asyncpg://user:password@127.0.0.1:5432/app
export RABBITMQ_URL=amqp://guest:guest@127.0.0.1:5672/
export REDIS_URL=redis://127.0.0.1:6379/0
### 5) Run backend
# Apply DB migrations (Alembic)
# From 7project/backend
alembic upgrade head
```bash
cd backend
# Run API
#TODO: set env variables
uvicorn app.app:fastApi --reload --host 0.0.0.0 --port 8000
```
# Run Celery worker (optional, for emails/background tasks)
### 6) Run Celery worker (optional, in another terminal)
```bash
cd Group-8/7project/backend
source .venv/bin/activate
celery -A app.celery_app.celery_app worker -l info
```
Frontend
### 7) Install frontend dependencies and run
```bash
# Configure backend URL for dev
echo 'VITE_BACKEND_URL=http://127.0.0.1:8000' > .env
cd ../frontend
npm i
npm run dev
# Open http://localhost:5173
```
- Backend default: http://127.0.0.1:8000 (OpenAPI at /docs)
- Frontend default: http://localhost:5173
- Backend available at: http://127.0.0.1:8000 (OpenAPI at /docs)
- Frontend available at: http://localhost:5173
If needed, adjust compose services/ports in compose.yml.
## Build Instructions
### Backend
```bash
cd 7project/backend
# Dont forget to set correct image tag with your registry and name
# For example lukastrkan/cc-app-demo or gitea.ltrk.dev/lukas/cc-app-demo
docker buildx build --platform linux/amd64,linux/arm64 -t CHANGE_ME --push .
```
### Frontend
```bash
cd project7/frontend
npm ci
npm run build
```
## Deployment Instructions
### Local (Docker Compose)
### Setup Cluster
Described in the previous section (Manual Local Run)
Deployment should work on any Kubernetes cluster. However, we are using 4 TalosOS virtual machines (1 control plane, 3
workers)
running on top of Proxmox VE.
### Kubernetes (via OpenTofu + Helm)
1) Create at least 4 VMs with TalosOS (4 cores, 8 GB RAM, 200 GB disk)
2) Install talosctl for your OS: https://docs.siderolabs.com/talos/v1.10/getting-started/talosctl
3) Generate Talos config
4) Navigate to tofu directory
```bash
cd 7project/tofu
````
5) Set IP addresses in environment variables
```bash
CONTROL_PLANE_IP=<control-plane-ip>
WORKER1_IP=<worker1-ip>
WORKER2_IP=<worker2-ip>
WORKER3_IP=<worker3-ip>
WORKER4_IP=<worker4-ip>
....
```
6) Create config files
```bash
# change my-cluster to your desired cluster name
talosctl gen config my-cluster https://$CONTROL_PLANE_IP:6443
```
7) Edit the generated configs
Apply the following changes to `worker.yaml`:
1) Add mounts for persistent storage to `machine.kubelet.extraMounts` section:
```yaml
extraMounts:
- destination: /var/lib/longhorn
type: bindind.
source: /var/lib/longhorn
options:
- bind
- rshared
- rw
```
2) Change `machine.install.image` to image with extra modules:
```yaml
image: factory.talos.dev/metal-installer/88d1f7a5c4f1d3aba7df787c448c1d3d008ed29cfb34af53fa0df4336a56040b:v1.11.1
```
or you can use latest image generated at https://factory.talos.dev with following options:
- Bare-metal machine
- your Talos os version
- amd64 architecture
- siderolabs/iscsi-tools
- siderolabs/util-linux-tools
- (Optionally) siderolabs/qemu-guest-agent
Then copy "Initial Installation" value and paste it to the image field.
3) Add docker registry mirror to `machine.registries.mirrors` section:
```yaml
registries:
mirrors:
docker.io:
endpoints:
- https://mirror.gcr.io
- https://registry-1.docker.io
```
8) Apply configs to the VMs
```bash
talosctl apply-config --insecure --nodes $CONTROL_PLANE_IP --file controlplane.yaml
talosctl apply-config --insecure --nodes $WORKER1_IP --file worker.yaml
talosctl apply-config --insecure --nodes $WORKER2_IP --file worker.yaml
talosctl apply-config --insecure --nodes $WORKER3_IP --file worker.yaml
talosctl apply-config --insecure --nodes $WORKER4_IP --file worker.yaml
```
9) Boostrap the cluster and retrieve kubeconfig
```bash
export TALOSCONFIG=$(pwd)/talosconfig
talosctl config endpoint https://$CONTROL_PLANE_IP:6443
talosctl config node $CONTROL_PLANE_IP
talosctl bootstrap
talosctl kubeconfig .
```
You can now use k8s client like https://headlamp.dev/ with the generated kubeconfig file.
### Install base services to the cluster
1) Copy and edit variables
1) Provision platform services (RabbitMQ/Redis/ingress/tunnel/etc.) with OpenTofu
```bash
cd tofu
# copy and edit variables
cp terraform.tfvars.example terraform.tfvars
# authenticate to your cluster/cloud as needed, then:
```
- `metallb_ip_range` - set to range available in your network for load balancer services
- `mariadb_password` - password for internal mariadb user
- `mariadb_root_password` - password for root user
- `mariadb_user_name` - username for admin user
- `mariadb_user_host` - allowed hosts for admin user
- `mariadb_user_password` - password for admin user
- `metallb_maxscale_ip`, `metallb_service_ip`, `metallb_primary_ip`, `metallb_secondary_ip` - IPs for database
cluster,
set them to static IPs from the `metallb_ip_range`
- `s3_enabled`, `s3_bucket`, `s3_region`, `s3_endpoint`, `s3_key_id`, `s3_key_secret` - S3 compatible storage for
backups (optional)
- `phpmyadmin_enabled` - set to false if you want to disable phpmyadmin
- `rabbitmq-password` - password for RabbitMQ
- `cloudflare_account_id` - your Cloudflare account ID
- `cloudflare_api_token` - your Cloudflare API token with permissions to manage tunnels and DNS
- `cloudflare_email` - your Cloudflare account email
- `cloudflare_tunnel_name` - name for the tunnel
- `cloudflare_domain` - your domain name managed in Cloudflare
2) Deploy without Cloudflare module first
```bash
tofu init
tofu plan
tofu apply -exclude modules.cloudflare
```
3) Deploy rest of the modules
```bash
tofu apply
```
2) Deploy the app using Helm
```bash
# Set the namespace
kubectl create namespace myapp || true
### Configure deployment
# Install/upgrade the chart with required values
helm upgrade --install myapp charts/myapp-chart \
-n myapp \
-f charts/myapp-chart/values.yaml \
--set image.backend.repository=myorg/myapp-backend \
--set image.backend.tag=latest \
--set env.BACKEND_URL="https://myapp.example.com" \
--set env.FRONTEND_URL="https://myapp.example.com" \
--set env.SECRET="CHANGE_ME_SECRET"
```
Adjust values to your registry and domain. The charts NOTES.txt includes additional examples.
3) Expose and access
- If using Cloudflare Tunnel or an ingress, configure DNS accordingly (see tofu/modules/cloudflare and deployment/tunnel.yaml).
- For quick testing without ingress:
```bash
kubectl -n myapp port-forward deploy/myapp-backend 8000:8000
kubectl -n myapp port-forward deploy/myapp-frontend 5173:80
```
### Verification
```bash
# Check pods
kubectl -n myapp get pods
# Backend health
curl -i http://127.0.0.1:8000/
# OpenAPI
open http://127.0.0.1:8000/docs
# Frontend (if port-forwarded)
open http://localhost:5173
```
1) Create self-hosted runner with access to the cluster or make cluster publicly accessible
2) Change `jobs.deploy.runs-on` in `.github/workflows/deploy-prod.yml` and in `.github/workflows/deploy-pr.yaml` to your
runner label
3) Add variables to GitHub in repository settings:
- `PROD_DOMAIN` - base domain for deployments (e.g. ltrk.cz)
- `DEV_FRONTEND_BASE_DOMAIN` - base domain for your cloudflare pages
4) Add secrets to GitHub in repository settings:
- CLOUDFLARE_ACCOUNT_ID - same as in tofu/terraform.tfvars
- CLOUDFLARE_API_TOKEN - same as in tofu/terraform.tfvars
- DOCKER_USER - your docker registry username
- DOCKER_PASSWORD - your docker registry password
- KUBE_CONFIG - content of your kubeconfig file for the cluster
- PROD_DB_PASSWORD - same as MARIADB_PASSWORD
- PROD_RABBITMQ_PASSWORD - same as MARIADB_PASSWORD
- PROD_DB_ENCRYPTION_KEY - same as DB_ENCRYPTION_KEY
- MOJEID_CLIENT_ID
- MOJEID_CLIENT_SECRET
- BANKID_CLIENT_ID
- BANKID_CLIENT_SECRET
- CSAS_CLIENT_ID
- CSAS_CLIENT_SECRET
- SENTRY_DSN
- SMTP_HOST
- SMTP_PORT
- SMTP_USERNAME
- SMTP_PASSWORD
- SMTP_FROM
- UNIRATE_API_KEY
5) On Github open Actions tab, select "Deploy Prod" and run workflow manually
## Testing Instructions
The tests are located in 7project/backend/tests directory. All tests are run by GitHub actions on every pull request and
push to main.
See the workflow [here](../.github/workflows/run-tests.yml).
If you want to run the tests locally, the preferred way is to use a [bash script](src/backend/test_locally.sh)
that will start a test DB container with [docker compose](src/backend/docker-compose.test.yml) and remove it afterwards.
```bash
cd 7project/backend
bash test_locally.sh
```
### Unit Tests
There are 5 basic unit tests, since our services logic is very simple
```bash
# Commands to run unit tests
# For example:
# go test ./...
# npm test
bash test_locally.sh --only-unit
```
### Integration Tests
There are 9 basic unit tests, testing the individual backend API logic
```bash
# Commands to run integration tests
# Any setup required for integration tests
bash test_locally.sh --only-integration
```
### End-to-End Tests
There are 7 e2e tests, testing more complex app logic
```bash
# Commands to run e2e tests
# How to set up test environment
bash test_locally.sh --only-e2e
```
## Usage Examples
@@ -313,24 +528,24 @@ curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8000/authenticated-route
---
## Self-Assessment Table
## Progress Table
> Be honest and detailed in your assessments.
> This information is used for individual grading.
> Link to the specific commit on GitHub for each contribution.
| Task/Component | Assigned To | Status | Time Spent | Difficulty | Notes |
|-----------------------------------------------------------------------|-------------| ------------- |----------------|------------| ----------- |
| [Project Setup & Repository](https://github.com/dat515-2025/Group-8#) | Lukas | ✅ Complete | [X hours] | Medium | [Any notes] |
| [Design Document](https://github.com/dat515-2025/Group-8/blob/main/6design/design.md) | Both | ✅ Complete | 2 Hours | Easy | [Any notes] |
| [Backend API Development](https://github.com/dat515-2025/Group-8/tree/main/7project/backend/app/api) | Dejan | 🔄 In Progress | 10 hours | Medium | [Any notes] |
| [Database Setup & Models](https://github.com/dat515-2025/Group-8/tree/main/7project/backend/app/models) | Lukas | 🔄 In Progress | [X hours] | Medium | [Any notes] |
| [Frontend Development](https://github.com/dat515-2025/Group-8/tree/main/7project/frontend) | Dejan | 🔄 In Progress | 7 hours so far | Medium | [Any notes] |
| [Docker Configuration](https://github.com/dat515-2025/Group-8/blob/main/7project/compose.yml) | Lukas | ✅ Complete | [X hours] | Easy | [Any notes] |
| [Cloud Deployment](https://github.com/dat515-2025/Group-8/blob/main/7project/deployment/app-demo-deployment.yaml) | Lukas | ✅ Complete | [X hours] | Hard | [Any notes] |
| [Testing Implementation](https://github.com/dat515-2025/group-name) | Dejan | 🔄 In Progress | [X hours] | Medium | [Any notes] |
| [Documentation](https://github.com/dat515-2025/group-name) | Both | 🔄 In Progress | [X hours] | Easy | [Any notes] |
| [Presentation Video](https://github.com/dat515-2025/group-name) | Both | ❌ Not Started | [X hours] | Medium | [Any notes] |
| Task/Component | Assigned To | Status | Time Spent | Difficulty | Notes |
|-------------------------------------------------------------------------------------------------------------------|-------------|----------------|------------|------------|-----------------------------------------------------------------------------------------------------|
| [Project Setup & Repository](https://github.com/dat515-2025/Group-8#) | Lukas | ✅ Complete | [X hours] | Medium | [Any notes] |
| [Design Document](https://github.com/dat515-2025/Group-8/blob/main/6design/design.md) | Both | ✅ Complete | 4 Hours | Easy | [Any notes] |
| [Backend API Development](https://github.com/dat515-2025/Group-8/tree/main/7project/backend/app/api) | Dejan | ✅ Complete | 12 hours | Medium | [Any notes] |
| [Database Setup & Models](https://github.com/dat515-2025/Group-8/tree/main/7project/backend/app/models) | Lukas | ✅ Complete | [X hours] | Medium | [Any notes] |
| [Frontend Development](https://github.com/dat515-2025/Group-8/tree/main/7project/frontend) | Dejan | ✅ Complete | 17 hours | Medium | [Any notes] |
| [Docker Configuration](https://github.com/dat515-2025/Group-8/blob/main/7project/compose.yml) | Lukas | ✅ Complete | 3 hours | Easy | [Any notes] |
| [Cloud Deployment](https://github.com/dat515-2025/Group-8/blob/main/7project/deployment/app-demo-deployment.yaml) | Lukas | ✅ Complete | [X hours] | Hard | Using Talos cluster running in proxmox - easy snapshots etc. Frontend deployed at Cloudflare pages. |
| [Testing Implementation](https://github.com/dat515-2025/group-name) | Dejan | ✅ Complete | 16 hours | Medium | [Any notes] |
| [Documentation](https://github.com/dat515-2025/group-name) | Both | 🔄 In Progress | [X hours] | Easy | [Any notes] |
| [Presentation Video](https://github.com/dat515-2025/group-name) | Both | ❌ Not Started | [X hours] | Medium | [Any notes] |
**Legend**: ✅ Complete | 🔄 In Progress | ⏳ Pending | ❌ Not Started
@@ -340,27 +555,46 @@ curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8000/authenticated-route
### [Lukáš]
| Date | Activity | Hours | Description |
|----------------|---------------------|------------|----------------------------------------------------|
| 4.10 to 10.10 | Initial Setup | 40 | Repository setup, project structure, cluster setup |
| 14.10 to 16.10 | Backend Development | 12 | Implemented user authentication - oauth |
| 8.10 to 12.10 | CI/CD | 10 | Created database schema and models |
| [Date] | Testing | [X.X] | Unit tests for API endpoints |
| [Date] | Documentation | [X.X] | Updated README and design doc |
| **Total** | | **[XX.X]** | |
## Hour Sheet
**Name:** Lukáš Trkan
| Date | Activity | Hours | Description | Representative Commit / PR |
|:----------------|:----------------------------|:--------|:------------------------------------------------------------------------------------|:------------------------------------------------------|
| 18.9. - 19.9. | Initial Setup & Design | 40 | Repository init, system design diagrams, basic Terraform setup | `feat(infrastructure): add basic terraform resources` |
| 20.9. - 5.10. | Core Infrastructure & CI/CD | 12 | K8s setup (ArgoCD), CI/CD workflows, RabbitMQ, Redis, Celery workers, DB migrations | `PR #2`, `feat(infrastructure): add rabbitmq cluster` |
| 6.10. - 9.10. | Frontend Infra & DB | 5 | Deployed frontend to Cloudflare, setup metrics, created database models | `PR #16` (Cloudflare), `PR #19` (DB structure) |
| 10.10. - 11.10. | Backend | 5 | Implemented OAuth support (MojeID, BankID) | `feat(auth): add support for OAuth and MojeID` |
| 12.10. | Infrastructure | 2 | Added database backups | `feat(infrastructure): add backups` |
| 16.10. | Infrastructure | 4 | Implemented secrets management, fixed deployment/env variables | `PR #29` (Deployment envs) |
| 17.10. | Monitoring | 1 | Added Sentry logging | `feat(app): add sentry loging` |
| 21.10. - 22.10. | Backend | 8 | Added ČSAS bank connection | `PR #32` (Fix React OAuth) |
| 29.10. - 30.10. | Backend | 5 | Implemented transaction encryption, add bank scraping | `PR #39` (CSAS Scraping) |
| 30.10. | Monitoring | 6 | Implemented Loki logging and basic Prometheus metrics | `PR #42` (Prometheus metrics) |
| 9.11. | Monitoring | 2 | Added custom Prometheus metrics | `PR #46` (Prometheus custom metrics) |
| 11.11. | Tests | 1 | Investigated and fixed broken Pytest environment | `fix(tests): set pytest env` |
| 11.11. - 12.11. | Features & Deployment | 6 | Added cron support, email sender service, updated workers & image | `PR #49` (Email), `PR #50` (Update workers) |
| 18.9 - 14.11 | Documentation | 8 | Updated report.md, design docs, and tfvars.example | `Create design.md`, `update report` |
| **Total** | | **105** | | |
### Dejan
| Date | Activity | Hours | Description |
|-----------------|----------------------|--------|----------------------------------------------------------------------------------|
| 25.9. | Design | 2 | 6design |
| 9.10 to 11.10. | Backend APIs | 10 | Implemented Backend APIs |
| 13.10 to 15.10. | Frontend Development | 7 | Created user interface mockups |
| Continually | Documantation | 5 | Documenting the dev process |
| 21.10 to 23.10 | Tests, forntend | 10 | Test basics, balance charts, and frontend improvement |
| 28.10 to 30.10 | Tests, forntend | 7 | Tests improvement with test database setup, UI fix and exchange rate integration |
| **Total** | | **41** | |
| Date | Activity | Hours | Description | Representative Commit / PR |
|:----------------|:-------------------------|:-------|:--------------------------------------------------------------|:---------------------------------------------------------|
| 25.9. | Design | 2 | 6design | |
| 9.10 to 11.10. | Backend APIs | 14 | Implemented Backend APIs | `PR #26`, `20-create-a-controller-layer-on-backend-side` |
| 13.10 to 15.10. | Frontend Development | 8 | Created user interface mockups | `PR #28`, `frontend basics` |
| Continually | Documentation | 8 | Documenting the dev process | |
| 21.10 to 23.10 | Tests, frontend | 10 | Test basics, balance charts, and frontend improvement | `PR #31`, `30 create tests and set up a GitHub pipeline` |
| 28.10 to 30.10 | CI | 6 | Integrated tests with test database setup on github workflows | `PR #28`, `frontend basics` |
| 28.10 to 30.10 | Frontend | 8 | UI improvements and exchange rate API integration | `PR #28`, `frontend basics` |
| 4.11 to 6.11 | Tests | 6 | Test fixes improvement, more integration and e2e | `PR #28`, `frontend basics` |
| 4.11 to 6.11 | Frontend | 6 | Fixes, Improved UI, added support for mobile devices | `PR #28`, `frontend basics` |
| 11.11 | Backend APIs | 4 | Moved rates API, mock bank to Backend, few fixes | `PR #28`, `frontend basics` |
| 11.11 to 12.11 | Tests | 3 | Local testing DB container, few fixes | `PR #28`, `frontend basics` |
| 12.11 | Frontend | 3 | Enabled multiple transaction edits at once, CSAS button state | `PR #28`, `frontend basics` |
| 13.11 | Video | 3 | Video | |
| **Total** | | **81** | | |
### Group Total: [XXX.X] hours
@@ -374,19 +608,46 @@ curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8000/authenticated-route
### Challenges Faced
[Describe the main challenges and how you overcame them]
#### Slow cluster performance
This was caused by single SATA SSD disk running all VMs. This was solved by adding second NVMe disk just for Talos VMs.
#### Stucked IaC deployment
If the deployed module (helm chart for example) was not configured properly, it would get stuck and timeout resulting in
namespace that cannot be deleted.
This was solved by using snapshots in Proxmox and restoring if this happened.
### If We Did This Again
#### Different framework
FastAPI lacks usable build in support for database migrations and implementing Alembic was a bit tricky.
Tricky was also integrating FastAPI auth system with React frontend, since there is no official project template.
Using .NET (which we considered initially) would probably solve these issues.
[What would you do differently? What worked well that you'd keep?]
### Individual Growth
#### [Team Member 1 Name]
#### [Lukas]
This course finally forced me to learn kubernetes (been on by TODO list for at least 3 years).
I had some prior experience with terraform/opentofu from work but this improved by understanding of it.
The biggest challenge for me was time tracking since I am used to tracking to projects, not to tasks.
(I am bad even at that :) ).
It was also interesting experience to be the one responsible for the initial project structure/design/setup
used not only by myself.
[Personal reflection on growth, challenges, and learning]
#### [Team Member 2 Name]
#### [Dejan]
Since I do not have a job, this project was probably the most complex one I have ever worked on.
It was also the first school project where I was encouraged to use AI.
Lukas
[Personal reflection on growth, challenges, and learning]
@@ -394,4 +655,4 @@ curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8000/authenticated-route
---
**Report Completion Date**: [Date]
**Last Updated**: 15.10.2025
**Last Updated**: 13.11.2025

8
7project/src/backend/.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@@ -1,4 +1,4 @@
FROM python:3.11-slim
FROM python:3.11-trixie
WORKDIR /app
COPY requirements.txt .

View File

@@ -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

View File

@@ -0,0 +1,116 @@
from datetime import datetime, timedelta
from typing import List, Optional
import random
from fastapi import APIRouter, Depends
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")
async def scrape_mock_bank():
# 80% of the time: nothing to scrape
if random.random() < 0.8:
return []
transactions = []
count = random.randint(1, 10)
for _ in range(count):
transactions.append({
"amount": round(random.uniform(-200.0, 200.0), 2),
"date": (datetime.utcnow().date() - timedelta(days=random.randint(0, 30))).isoformat(),
"description": "Mock transaction",
})
return transactions

View File

@@ -1,14 +1,17 @@
import json
import logging
import os
import sys
from datetime import datetime
from pythonjsonlogger import jsonlogger
from fastapi import Depends, FastAPI
from fastapi import Depends, FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from prometheus_fastapi_instrumentator import Instrumentator, metrics
from starlette.requests import Request
from app.services.prometheus import number_of_users, number_of_transactions
from app.services import bank_scraper
from app.workers.celery_tasks import load_transactions, load_all_transactions
from app.models.user import User, OAuthAccount
@@ -18,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
@@ -26,7 +30,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"),
@@ -48,18 +53,23 @@ fastApi.add_middleware(
allow_headers=["*"],
)
prometheus = Instrumentator().instrument(fastApi)
prometheus.expose(
fastApi,
endpoint="/metrics",
include_in_schema=True,
)
if not os.getenv("PYTEST_RUN_CONFIG"):
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)
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)
@@ -73,7 +83,6 @@ _log_handler.setFormatter(_formatter)
logging.root.setLevel(logging.INFO)
logging.root.addHandler(_log_handler)
for _name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
_logger = logging.getLogger(_name)
_logger.handlers = [_log_handler]
@@ -156,16 +165,12 @@ async def authenticated_route(user: User = Depends(current_active_verified_user)
return {"message": f"Hello {user.email}!"}
@fastApi.get("/debug/scrape/csas/all", tags=["debug"])
async def debug_scrape_csas_all():
logging.info("[Debug] Queueing CSAS scrape for all users via HTTP endpoint (Celery)")
@fastApi.get("/_cron", include_in_schema=False)
async def handle_cron(request: Request):
# endpoint accessed by Clodflare => return 404
if request.headers.get("cf-connecting-ip"):
raise HTTPException(status_code=404)
logging.info("[Cron] Triggering scheduled tasks via HTTP endpoint")
task = load_all_transactions.delay()
return {"status": "queued", "action": "csas_scrape_all", "task_id": getattr(task, 'id', None)}
@fastApi.post("/debug/scrape/csas/{user_id}", tags=["debug"])
async def debug_scrape_csas_user(user_id: str, user: User = Depends(current_active_verified_user)):
logging.info("[Debug] Queueing CSAS scrape for single user via HTTP endpoint (Celery) | user_id=%s", user_id)
task = load_transactions.delay(user_id)
return {"status": "queued", "action": "csas_scrape_single", "user_id": user_id,
"task_id": getattr(task, 'id', None)}

View File

@@ -1,5 +1,7 @@
import os
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.core.base import Base
DATABASE_URL = os.getenv("DATABASE_URL")
@@ -23,6 +25,7 @@ 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 {}
# Async engine/session for the async parts of the app
engine = create_async_engine(
DATABASE_URL,
pool_pre_ping=True,
@@ -30,3 +33,13 @@ engine = create_async_engine(
connect_args=connect_args,
)
async_session_maker = async_sessionmaker(engine, expire_on_commit=False)
# Synchronous engine/session for sync utilities (e.g., bank_scraper)
SYNC_DATABASE_URL = DATABASE_URL.replace("+asyncmy", "+pymysql")
engine_sync = create_engine(
SYNC_DATABASE_URL,
pool_pre_ping=True,
echo=os.getenv("SQL_ECHO", "0") == "1",
connect_args=connect_args,
)
sync_session_maker = sessionmaker(bind=engine_sync, expire_on_commit=False)

View File

@@ -1,10 +1,11 @@
import uuid
from typing import Optional
from typing import Optional, Dict, Any
from fastapi_users import schemas
class UserRead(schemas.BaseUser[uuid.UUID]):
first_name: Optional[str] = None
last_name: Optional[str] = None
config: Optional[Dict[str, Any]] = None
class UserCreate(schemas.BaseUserCreate):
first_name: Optional[str] = None

View File

@@ -1,5 +1,6 @@
import json
import logging
import os
from os.path import dirname, join
from time import strptime
from uuid import UUID
@@ -7,7 +8,7 @@ from uuid import UUID
import httpx
from sqlalchemy import select
from app.core.db import async_session_maker
from app.core.db import sync_session_maker
from app.models.transaction import Transaction
from app.models.user import User
@@ -20,26 +21,78 @@ CERTS = (
)
async def aload_ceska_sporitelna_transactions(user_id: str) -> None:
def load_mock_bank_transactions(user_id: str) -> None:
try:
uid = UUID(str(user_id))
except Exception:
logger.error("Invalid user_id provided to bank_scraper (async): %r", user_id)
logger.error("Invalid user_id provided to bank_scraper (sync): %r", user_id)
return
await _aload_ceska_sporitelna_transactions(uid)
_load_mock_bank_transactions(uid)
async def aload_all_ceska_sporitelna_transactions() -> None:
async with async_session_maker() as session:
result = await session.execute(select(User))
users = result.unique().scalars().all()
def load_all_mock_bank_transactions() -> None:
with sync_session_maker() as session:
users = session.execute(select(User)).unique().scalars().all()
logger.info("[BankScraper] Starting Mock Bank scrape for all users | count=%d", len(users))
processed = 0
for user in users:
try:
_load_mock_bank_transactions(user.id)
processed += 1
except Exception:
logger.exception("[BankScraper] Error scraping for user id=%s email=%s", user.id,
getattr(user, 'email', None))
logger.info("[BankScraper] Finished Mock Bank scrape for all users | processed=%d", processed)
def _load_mock_bank_transactions(user_id: UUID) -> None:
with sync_session_maker() as session:
user: User | None = session.execute(select(User).where(User.id == user_id)).unique().scalar_one_or_none()
if user is None:
logger.warning("User not found for id=%s", user_id)
return
transactions = []
with httpx.Client() as client:
response = client.get(f"{os.getenv('APP_POD_URL')}/mock-bank/scrape")
if response.status_code != httpx.codes.OK:
return
for transaction in response.json():
transactions.append(
Transaction(
amount=transaction["amount"],
description=transaction.get("description"),
date=strptime(transaction["date"], "%Y-%m-%d"),
user_id=user_id,
)
)
for transaction in transactions:
session.add(transaction)
session.commit()
def load_ceska_sporitelna_transactions(user_id: str) -> None:
try:
uid = UUID(str(user_id))
except Exception:
logger.error("Invalid user_id provided to bank_scraper (sync): %r", user_id)
return
_load_ceska_sporitelna_transactions(uid)
def load_all_ceska_sporitelna_transactions() -> None:
with sync_session_maker() as session:
users = session.execute(select(User)).unique().scalars().all()
logger.info("[BankScraper] Starting CSAS scrape for all users | count=%d", len(users))
processed = 0
for user in users:
try:
await _aload_ceska_sporitelna_transactions(user.id)
_load_ceska_sporitelna_transactions(user.id)
processed += 1
except Exception:
logger.exception("[BankScraper] Error scraping for user id=%s email=%s", user.id,
@@ -47,10 +100,9 @@ async def aload_all_ceska_sporitelna_transactions() -> None:
logger.info("[BankScraper] Finished CSAS scrape for all users | processed=%d", processed)
async def _aload_ceska_sporitelna_transactions(user_id: UUID) -> None:
async with (async_session_maker() as session):
result = await session.execute(select(User).where(User.id == user_id))
user: User = result.unique().scalar_one_or_none()
def _load_ceska_sporitelna_transactions(user_id: UUID) -> None:
with sync_session_maker() as session:
user: User | None = session.execute(select(User).where(User.id == user_id)).unique().scalar_one_or_none()
if user is None:
logger.warning("User not found for id=%s", user_id)
return
@@ -65,8 +117,8 @@ async def _aload_ceska_sporitelna_transactions(user_id: UUID) -> None:
accounts = []
try:
async with httpx.AsyncClient(cert=CERTS, timeout=httpx.Timeout(20.0)) as client:
response = await client.get(
with httpx.Client(cert=CERTS, timeout=httpx.Timeout(20.0)) as client:
response = client.get(
"https://webapi.developers.erstegroup.com/api/csas/sandbox/v4/account-information/my/accounts?size=10&page=0&sort=iban&order=desc",
headers={
"Authorization": f"Bearer {cfg['access_token']}",
@@ -77,7 +129,7 @@ async def _aload_ceska_sporitelna_transactions(user_id: UUID) -> None:
if response.status_code != httpx.codes.OK:
return
for account in response.json()["accounts"]:
for account in response.json().get("accounts", []):
accounts.append(account)
except (httpx.HTTPError,) as e:
@@ -85,11 +137,13 @@ async def _aload_ceska_sporitelna_transactions(user_id: UUID) -> None:
return
for account in accounts:
id = account["id"]
acc_id = account.get("id")
if not acc_id:
continue
url = f"https://webapi.developers.erstegroup.com/api/csas/sandbox/v4/account-information/my/accounts/{id}/transactions?size=100&page=0&sort=bookingdate&order=desc"
async with httpx.AsyncClient(cert=CERTS) as client:
response = await client.get(
url = f"https://webapi.developers.erstegroup.com/api/csas/sandbox/v4/account-information/my/accounts/{acc_id}/transactions?size=100&page=0&sort=bookingdate&order=desc"
with httpx.Client(cert=CERTS) as client:
response = client.get(
url,
headers={
"Authorization": f"Bearer {cfg['access_token']}",
@@ -100,7 +154,7 @@ async def _aload_ceska_sporitelna_transactions(user_id: UUID) -> None:
if response.status_code != httpx.codes.OK:
continue
transactions = response.json()["transactions"]
transactions = response.json().get("transactions", [])
for transaction in transactions:
description = transaction.get("entryDetails", {}).get("transactionDetails", {}).get(
@@ -108,9 +162,12 @@ async def _aload_ceska_sporitelna_transactions(user_id: UUID) -> None:
date_str = transaction.get("bookingDate", {}).get("date")
date = strptime(date_str, "%Y-%m-%d") if date_str else None
amount = transaction.get("amount", {}).get("value")
if transaction.get("creditDebitIndicator") == "DBIT":
if transaction.get("creditDebitIndicator") == "DBIT" and amount is not None:
amount = -abs(amount)
if amount is None:
continue
obj = Transaction(
amount=amount,
description=description,
@@ -118,7 +175,4 @@ async def _aload_ceska_sporitelna_transactions(user_id: UUID) -> None:
user_id=user_id,
)
session.add(obj)
await session.commit()
pass
pass
session.commit()

View File

@@ -0,0 +1,48 @@
from typing import Callable
from prometheus_fastapi_instrumentator.metrics import Info
from prometheus_client import Gauge
from sqlalchemy import select, func
from app.core.db import async_session_maker
from app.models.transaction import Transaction
from app.models.user import User
def number_of_users() -> Callable[[Info], None]:
METRIC = Gauge(
"number_of_users_total",
"Number of registered users.",
labelnames=("users",)
)
async def instrumentation(info: Info) -> None:
try:
async with async_session_maker() as session:
result = await session.execute(select(func.count(User.id)))
user_count = result.scalar_one() or 0
except Exception:
# In case of DB errors, avoid crashing metrics endpoint
user_count = 0
METRIC.labels(users="total").set(user_count)
return instrumentation
def number_of_transactions() -> Callable[[Info], None]:
METRIC = Gauge(
"number_of_transactions_total",
"Number of transactions stored.",
labelnames=("transactions",)
)
async def instrumentation(info: Info) -> None:
try:
async with async_session_maker() as session:
result = await session.execute(select(func.count()).select_from(Transaction))
transaction_count = result.scalar_one() or 0
except Exception:
# In case of DB errors, avoid crashing metrics endpoint
transaction_count = 0
METRIC.labels(transactions="total").set(transaction_count)
return instrumentation

View File

@@ -0,0 +1,86 @@
import logging
import os
import smtplib
from email.message import EmailMessage
import app.services.bank_scraper
from app.celery_app import celery_app
logger = logging.getLogger("celery_tasks")
if not logger.handlers:
_h = logging.StreamHandler()
logger.addHandler(_h)
logger.setLevel(logging.INFO)
@celery_app.task(name="workers.send_email")
def send_email(to: str, subject: str, body: str) -> None:
if not (to and subject and body):
logger.error("Email task missing fields. to=%r subject=%r body_len=%r", to, subject, len(body) if body else 0)
return
host = os.getenv("SMTP_HOST")
if not host:
logger.error("SMTP_HOST is not configured; cannot send email")
return
# Configuration
port = int(os.getenv("SMTP_PORT", "25"))
username = os.getenv("SMTP_USERNAME")
password = os.getenv("SMTP_PASSWORD")
use_tls = os.getenv("SMTP_USE_TLS", "0").lower() in {"1", "true", "yes"}
use_ssl = os.getenv("SMTP_USE_SSL", "0").lower() in {"1", "true", "yes"}
timeout = int(os.getenv("SMTP_TIMEOUT", "10"))
mail_from = os.getenv("SMTP_FROM") or username or "noreply@localhost"
# Build message
msg = EmailMessage()
msg["To"] = to
msg["From"] = mail_from
msg["Subject"] = subject
msg.set_content(body)
try:
if use_ssl:
with smtplib.SMTP_SSL(host=host, port=port, timeout=timeout) as smtp:
if username and password:
smtp.login(username, password)
smtp.send_message(msg)
else:
with smtplib.SMTP(host=host, port=port, timeout=timeout) as smtp:
# STARTTLS if requested
if use_tls:
smtp.starttls()
if username and password:
smtp.login(username, password)
smtp.send_message(msg)
logger.info("[Celery] Email sent | to=%s | subject=%s | body_len=%d", to, subject, len(body))
except Exception:
logger.exception("Failed to send email via SMTP to=%s subject=%s host=%s port=%s tls=%s ssl=%s", to, subject,
host, port, use_tls, use_ssl)
@celery_app.task(name="workers.load_transactions")
def load_transactions(user_id: str) -> None:
if not user_id:
logger.error("Load transactions task missing user_id.")
return
logger.info("[Celery] Starting load_transactions | user_id=%s", user_id)
try:
# Use synchronous bank scraper functions directly, mirroring load_all_transactions
app.services.bank_scraper.load_mock_bank_transactions(user_id)
app.services.bank_scraper.load_ceska_sporitelna_transactions(user_id)
except Exception:
logger.exception("Failed to load transactions for user_id=%s", user_id)
else:
logger.info("[Celery] Finished load_transactions | user_id=%s", user_id)
@celery_app.task(name="workers.load_all_transactions")
def load_all_transactions() -> None:
logger.info("[Celery] Starting load_all_transactions")
# Now use synchronous bank scraper functions directly
app.services.bank_scraper.load_all_mock_bank_transactions()
app.services.bank_scraper.load_all_ceska_sporitelna_transactions()
logger.info("[Celery] Finished load_all_transactions")

View File

@@ -0,0 +1,20 @@
version: "3.9"
services:
mariadb:
image: mariadb:11.4
container_name: test-mariadb
environment:
MARIADB_ROOT_PASSWORD: rootpw
MARIADB_DATABASE: group_project
MARIADB_USER: appuser
MARIADB_PASSWORD: apppass
ports:
- "3307:3306" # host:container (use 3307 on host to avoid conflicts)
healthcheck:
test: ["CMD", "mariadb-admin", "ping", "-h", "127.0.0.1", "-u", "root", "-prootpw", "--silent"]
interval: 5s
timeout: 2s
retries: 20
# Truly ephemeral, fast storage (removed when container stops)
tmpfs:
- /var/lib/mysql

View File

@@ -0,0 +1,113 @@
#!/usr/bin/env bash
set -euo pipefail
# Run tests against a disposable local MariaDB on host port 3307 using Docker Compose.
# Requirements: Docker, docker compose plugin, Python, Alembic, pytest.
# Usage:
# chmod +x ./test_locally.sh
# # From 7project/backend directory
# ./test_locally.sh [--only-unit|--only-integration|--only-e2e] [pytest-args...]
# # Examples:
# ./test_locally.sh --only-unit -q
# ./test_locally.sh --only-integration -k "login"
# ./test_locally.sh --only-e2e -vv
#
# This script will:
# 1) Start a MariaDB 11.4 container (ephemeral storage, port 3307)
# 2) Wait until it's healthy
# 3) Export env vars expected by the app (DATABASE_URL etc.)
# 4) Run Alembic migrations
# 5) Run pytest
# 6) Tear everything down (containers and tmpfs data)
COMPOSE_FILE="docker-compose.test.yml"
SERVICE_NAME="mariadb"
CONTAINER_NAME="test-mariadb"
if ! command -v docker >/dev/null 2>&1; then
echo "Docker is required but not found in PATH" >&2
exit 1
fi
if ! docker compose version >/dev/null 2>&1; then
echo "Docker Compose V2 plugin is required (docker compose)" >&2
exit 1
fi
# Bring up the DB
echo "Starting MariaDB (port 3307) with docker compose..."
docker compose -f "$COMPOSE_FILE" up -d
# Ensure we clean up on exit
cleanup() {
echo "\nTearing down docker compose stack..."
docker compose -f "$COMPOSE_FILE" down -v || true
}
trap cleanup EXIT
# Wait for healthy container
echo -n "Waiting for MariaDB to become healthy"
for i in {1..60}; do
status=$(docker inspect -f '{{.State.Health.Status}}' "$CONTAINER_NAME" 2>/dev/null || echo "")
if [ "$status" = "healthy" ]; then
echo " -> healthy"
break
fi
echo -n "."
sleep 1
if [ $i -eq 60 ]; then
echo "\nMariaDB did not become healthy in time" >&2
exit 1
fi
done
# Export env vars for the app/tests (match app/core/db.py expectations)
export MARIADB_HOST=127.0.0.1
export MARIADB_PORT=3307
export MARIADB_DB=group_project
export MARIADB_USER=appuser
export MARIADB_PASSWORD=apppass
export DATABASE_URL="mysql+asyncmy://$MARIADB_USER:$MARIADB_PASSWORD@$MARIADB_HOST:$MARIADB_PORT/$MARIADB_DB"
export PYTEST_RUN_CONFIG="True"
# Determine which tests to run based on flags
UNIT_TESTS="tests/test_unit_user_service.py"
INTEGRATION_TESTS="tests/test_integration_app.py"
E2E_TESTS="tests/test_e2e.py"
FLAG_COUNT=0
TEST_TARGET=""
declare -a PYTEST_ARGS=()
for arg in "$@"; do
case "$arg" in
--only-unit)
TEST_TARGET="$UNIT_TESTS"; FLAG_COUNT=$((FLAG_COUNT+1));;
--only-integration)
TEST_TARGET="$INTEGRATION_TESTS"; FLAG_COUNT=$((FLAG_COUNT+1));;
--only-e2e)
TEST_TARGET="$E2E_TESTS"; FLAG_COUNT=$((FLAG_COUNT+1));;
*)
PYTEST_ARGS+=("$arg");;
esac
done
if [ "$FLAG_COUNT" -gt 1 ]; then
echo "Error: Use only one of --only-unit, --only-integration, or --only-e2e" >&2
exit 2
fi
# Run Alembic migrations then tests
pushd . >/dev/null
echo "Running Alembic migrations..."
alembic upgrade head
echo "Running pytest..."
if [ -n "$TEST_TARGET" ]; then
# Use "${PYTEST_ARGS[@]:-}" to safely expand empty array with 'set -u'
pytest "$TEST_TARGET" "${PYTEST_ARGS[@]:-}"
else
# Use "${PYTEST_ARGS[@]:-}" to safely expand empty array with 'set -u'
pytest "${PYTEST_ARGS[@]:-}"
fi
popd >/dev/null
# Cleanup handled by trap

View File

@@ -0,0 +1,210 @@
import pytest
import uuid
from httpx import AsyncClient, ASGITransport
from fastapi import status
def test_e2e(client):
# 1) Service is alive
alive = client.get("/")
assert alive.status_code == status.HTTP_200_OK
# 2) Attempt to login without payload should fail fast (validation error)
login = client.post("/auth/jwt/login")
assert login.status_code in (status.HTTP_400_BAD_REQUEST, status.HTTP_422_UNPROCESSABLE_CONTENT)
# 3) Protected endpoint should not be accessible without token
me = client.get("/users/me")
assert me.status_code in (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN)
@pytest.mark.asyncio
async def test_e2e_full_user_lifecycle(fastapi_app, test_user):
# Use an AsyncClient with ASGITransport for async tests
transport = ASGITransport(app=fastapi_app, raise_app_exceptions=True)
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
login_payload = test_user
# 1. Log in with the new credentials
login_resp = await ac.post("/auth/jwt/login", data=login_payload)
assert login_resp.status_code == status.HTTP_200_OK
token = login_resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
# 2. Access a protected endpoint
me_resp = await ac.get("/users/me", headers=headers)
assert me_resp.status_code == status.HTTP_200_OK
assert me_resp.json()["email"] == test_user["username"]
# 3. Update the user's profile
update_payload = {"first_name": "Test"}
patch_resp = await ac.patch("/users/me", json=update_payload, headers=headers)
assert patch_resp.status_code == status.HTTP_200_OK
assert patch_resp.json()["first_name"] == "Test"
# 4. Log out
logout_resp = await ac.post("/auth/jwt/logout", headers=headers)
assert logout_resp.status_code in (status.HTTP_200_OK, status.HTTP_204_NO_CONTENT)
# 5. Verify token is invalid
me_again_resp = await ac.get("/users/me", headers=headers)
assert me_again_resp.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.asyncio
async def test_e2e_transaction_workflow(fastapi_app, test_user):
transport = ASGITransport(app=fastapi_app, raise_app_exceptions=True)
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
# 1. Log in to get the token
login_resp = await ac.post("/auth/jwt/login", data=test_user)
token = login_resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
# NEW STEP: Create a category first to get a valid ID
category_payload = {"name": "Test Category for E2E"}
create_category_resp = await ac.post("/categories/create", json=category_payload, headers=headers)
assert create_category_resp.status_code == status.HTTP_201_CREATED
category_id = create_category_resp.json()["id"]
# 2. Create a new transaction
tx_payload = {"amount": -55.40, "description": "Milk and eggs"}
tx_resp = await ac.post("/transactions/create", json=tx_payload, headers=headers)
assert tx_resp.status_code == status.HTTP_201_CREATED
tx_id = tx_resp.json()["id"]
# 3. Assign the category
assign_resp = await ac.post(f"/transactions/{tx_id}/categories/{category_id}", headers=headers)
assert assign_resp.status_code == status.HTTP_200_OK
# 4. Verify assignment
get_tx_resp = await ac.get(f"/transactions/{tx_id}", headers=headers)
assert category_id in get_tx_resp.json()["category_ids"]
# 5. Unassign the category
unassign_resp = await ac.delete(f"/transactions/{tx_id}/categories/{category_id}", headers=headers)
assert unassign_resp.status_code == status.HTTP_200_OK
# 6. Get the transaction again and verify the category is gone
get_tx_again_resp = await ac.get(f"/transactions/{tx_id}", headers=headers)
final_tx_data = get_tx_again_resp.json()
assert category_id not in final_tx_data["category_ids"]
# 7. Delete the transaction for cleanup
delete_resp = await ac.delete(f"/transactions/{tx_id}/delete", headers=headers)
assert delete_resp.status_code in (status.HTTP_200_OK, status.HTTP_204_NO_CONTENT)
# NEW STEP: Clean up the created category
delete_category_resp = await ac.delete(f"/categories/{category_id}", headers=headers)
assert delete_category_resp.status_code in (status.HTTP_200_OK, status.HTTP_204_NO_CONTENT)
@pytest.mark.asyncio
async def test_register_then_login_and_fetch_me(fastapi_app):
transport = ASGITransport(app=fastapi_app, raise_app_exceptions=True)
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
# Use unique email to avoid duplicates across runs
suffix = uuid.uuid4().hex[:8]
email = f"newuser_{suffix}@example.com"
password = "StrongPassw0rd!"
reg = await ac.post("/auth/register", json={"email": email, "password": password})
assert reg.status_code in (status.HTTP_201_CREATED, status.HTTP_200_OK)
login = await ac.post("/auth/jwt/login", data={"username": email, "password": password})
assert login.status_code == status.HTTP_200_OK
token = login.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
try:
me = await ac.get("/users/me", headers=headers)
assert me.status_code == status.HTTP_200_OK
assert me.json()["email"] == email
finally:
# Cleanup: delete the created user so future runs wont conflict
d = await ac.delete("/users/me", headers=headers)
assert d.status_code == status.HTTP_204_NO_CONTENT
@pytest.mark.asyncio
async def test_delete_current_user_revokes_access(fastapi_app):
transport = ASGITransport(app=fastapi_app, raise_app_exceptions=True)
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
email = "todelete@example.com"
password = "Passw0rd!"
reg = await ac.post("/auth/register", json={"email": email, "password": password})
assert reg.status_code in (status.HTTP_200_OK, status.HTTP_201_CREATED)
login = await ac.post("/auth/jwt/login", data={"username": email, "password": password})
token = login.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
# Delete self
d = await ac.delete("/users/me", headers=headers)
assert d.status_code == status.HTTP_204_NO_CONTENT
# Access should now fail
me = await ac.get("/users/me", headers=headers)
assert me.status_code in (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN)
@pytest.mark.asyncio
async def test_update_category_conflict_and_404(fastapi_app, test_user):
transport = ASGITransport(app=fastapi_app)
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
token = (await ac.post("/auth/jwt/login", data=test_user)).json()["access_token"]
h = {"Authorization": f"Bearer {token}"}
a = (await ac.post("/categories/create", json={"name": "A"}, headers=h)).json()
b = (await ac.post("/categories/create", json={"name": "B"}, headers=h)).json()
# Attempt to rename A -> B should conflict
conflict = await ac.patch(f"/categories/{a['id']}", json={"name": "B"}, headers=h)
assert conflict.status_code == status.HTTP_409_CONFLICT
# Update non-existent
missing = await ac.patch("/categories/999999", json={"name": "Z"}, headers=h)
assert missing.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.asyncio
async def test_category_cross_user_isolation(fastapi_app):
transport = ASGITransport(app=fastapi_app)
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
# Generate unique emails for both users
sfx = uuid.uuid4().hex[:8]
u1 = {"email": f"u1_{sfx}@example.com", "password": "Aaaaaa1!"}
u2 = {"email": f"u2_{sfx}@example.com", "password": "Aaaaaa1!"}
# user1
assert (await ac.post("/auth/register", json=u1)).status_code in (200, 201)
t1 = (await ac.post("/auth/jwt/login", data={"username": u1["email"], "password": u1["password"]})).json()["access_token"]
h1 = {"Authorization": f"Bearer {t1}"}
# user1 creates a category
c = (await ac.post("/categories/create", json={"name": "Private"}, headers=h1)).json()
cat_id = c["id"]
# user2
assert (await ac.post("/auth/register", json=u2)).status_code in (200, 201)
t2 = (await ac.post("/auth/jwt/login", data={"username": u2["email"], "password": u2["password"]})).json()["access_token"]
h2 = {"Authorization": f"Bearer {t2}"}
try:
# user2 cannot read/delete user1's category
g = await ac.get(f"/categories/{cat_id}", headers=h2)
assert g.status_code == status.HTTP_404_NOT_FOUND
d = await ac.delete(f"/categories/{cat_id}", headers=h2)
assert d.status_code == status.HTTP_404_NOT_FOUND
finally:
# Cleanup: remove the created category as its owner
try:
_ = await ac.delete(f"/categories/{cat_id}", headers=h1)
except Exception:
pass
# Cleanup: delete both users to avoid email conflicts later
try:
_ = await ac.delete("/users/me", headers=h1)
except Exception:
pass
try:
_ = await ac.delete("/users/me", headers=h2)
except Exception:
pass

View File

@@ -0,0 +1,159 @@
from fastapi import status
import pytest
from httpx import AsyncClient, ASGITransport
@pytest.mark.asyncio
async def test_create_and_get_category(fastapi_app, test_user):
# Use AsyncClient for async tests
transport = ASGITransport(app=fastapi_app)
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
# 1. Log in to get an auth token
login_resp = await ac.post("/auth/jwt/login", data=test_user)
token = login_resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
# 2. Define and create the new category
category_name = "Async Integration Test"
category_payload = {"name": category_name}
create_resp = await ac.post("/categories/create", json=category_payload, headers=headers)
# 3. Assert creation was successful
assert create_resp.status_code == status.HTTP_201_CREATED
created_data = create_resp.json()
category_id = created_data["id"]
assert created_data["name"] == category_name
# 4. GET the list of categories to verify
list_resp = await ac.get("/categories/", headers=headers)
assert list_resp.status_code == status.HTTP_200_OK
# 5. Check that our new category is in the list
categories_list = list_resp.json()
assert any(cat["name"] == category_name for cat in categories_list)
delete_resp = await ac.delete(f"/categories/{category_id}", headers=headers)
assert delete_resp.status_code in (status.HTTP_200_OK, status.HTTP_204_NO_CONTENT)
@pytest.mark.asyncio
async def test_create_transaction_missing_amount_fails(fastapi_app, test_user):
transport = ASGITransport(app=fastapi_app)
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
# 1. Log in to get an auth token
login_resp = await ac.post("/auth/jwt/login", data=test_user)
token = login_resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
# 2. Define an invalid payload
invalid_payload = {"description": "This should fail"}
# 3. Attempt to create the transaction
resp = await ac.post("/transactions/create", json=invalid_payload, headers=headers)
# 4. Assert the expected validation error
assert resp.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT
@pytest.mark.asyncio
async def test_login_invalid_credentials(fastapi_app, test_user):
transport = ASGITransport(app=fastapi_app)
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
bad = await ac.post("/auth/jwt/login", data={"username": test_user["username"], "password": "nope"})
assert bad.status_code in (status.HTTP_401_UNAUTHORIZED, status.HTTP_400_BAD_REQUEST)
unknown = await ac.post("/auth/jwt/login", data={"username": "nouser@example.com", "password": "x"})
assert unknown.status_code in (status.HTTP_401_UNAUTHORIZED, status.HTTP_400_BAD_REQUEST)
@pytest.mark.asyncio
async def test_category_duplicate_name_conflict(fastapi_app, test_user):
transport = ASGITransport(app=fastapi_app)
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
token = (await ac.post("/auth/jwt/login", data=test_user)).json()["access_token"]
h = {"Authorization": f"Bearer {token}"}
p = {"name": "Food"}
r1 = await ac.post("/categories/create", json=p, headers=h)
assert r1.status_code == status.HTTP_201_CREATED
r2 = await ac.post("/categories/create", json=p, headers=h)
assert r2.status_code == status.HTTP_409_CONFLICT
@pytest.mark.asyncio
async def test_create_transaction_invalid_date_format(fastapi_app, test_user):
transport = ASGITransport(app=fastapi_app)
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
token = (await ac.post("/auth/jwt/login", data=test_user)).json()["access_token"]
h = {"Authorization": f"Bearer {token}"}
bad = await ac.post("/transactions/create", json={"amount": 10, "description": "x", "date": "31-12-2024"}, headers=h)
assert bad.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.asyncio
async def test_update_transaction_rejects_duplicate_category_ids(fastapi_app, test_user):
transport = ASGITransport(app=fastapi_app)
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
token = (await ac.post("/auth/jwt/login", data=test_user)).json()["access_token"]
h = {"Authorization": f"Bearer {token}"}
tx = (await ac.post("/transactions/create", json={"amount": 5, "description": "x"}, headers=h)).json()
dup = await ac.patch(f"/transactions/{tx['id']}/edit", json={"category_ids": [1, 1]}, headers=h)
assert dup.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.asyncio
async def test_assign_unassign_category_not_found_cases(fastapi_app, test_user):
transport = ASGITransport(app=fastapi_app)
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
token = (await ac.post("/auth/jwt/login", data=test_user)).json()["access_token"]
h = {"Authorization": f"Bearer {token}"}
# Create tx and category
tx = (await ac.post("/transactions/create", json={"amount": 1, "description": "a"}, headers=h)).json()
cat = (await ac.post("/categories/create", json={"name": "X"}, headers=h)).json()
# Missing transaction
r1 = await ac.post(f"/transactions/999999/categories/{cat['id']}", headers=h)
assert r1.status_code == status.HTTP_404_NOT_FOUND
# Missing category
r2 = await ac.post(f"/transactions/{tx['id']}/categories/999999", headers=h)
assert r2.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.asyncio
async def test_transactions_date_filter_and_balance_series(fastapi_app, test_user):
transport = ASGITransport(app=fastapi_app)
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
token = (await ac.post("/auth/jwt/login", data=test_user)).json()["access_token"]
h = {"Authorization": f"Bearer {token}"}
# Seed transactions spanning days
data = [
{"amount": 100, "description": "day1", "date": "2024-01-01"},
{"amount": -25, "description": "day2", "date": "2024-01-02"},
{"amount": 50, "description": "day3", "date": "2024-01-03"},
]
for p in data:
r = await ac.post("/transactions/create", json=p, headers=h)
assert r.status_code == status.HTTP_201_CREATED
# Filtered list (2nd and 3rd only)
lst = await ac.get("/transactions/", params={"start_date": "2024-01-02", "end_date": "2024-01-03"}, headers=h)
assert lst.status_code == status.HTTP_200_OK
assert len(lst.json()) == 2
# Balance series should be cumulative per date
series = await ac.get("/transactions/balance_series", headers=h)
assert series.status_code == status.HTTP_200_OK
s = series.json()
assert s == [
{"date": "2024-01-01", "balance": 100.0},
{"date": "2024-01-02", "balance": 75.0},
{"date": "2024-01-03", "balance": 125.0},
]
@pytest.mark.asyncio
async def test_delete_transaction_not_found(fastapi_app, test_user):
transport = ASGITransport(app=fastapi_app)
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
token = (await ac.post("/auth/jwt/login", data=test_user)).json()["access_token"]
h = {"Authorization": f"Bearer {token}"}
r = await ac.delete("/transactions/9999999/delete", headers=h)
assert r.status_code == status.HTTP_404_NOT_FOUND

View File

@@ -1,7 +1,5 @@
import types
import asyncio
import pytest
from fastapi import status
from app.services import user_service
@@ -22,6 +20,15 @@ def test_get_jwt_strategy_lifetime():
# Basic smoke check: strategy has a lifetime set to 604800
assert getattr(strategy, "lifetime_seconds", None) in (604800,)
def test_root_ok(client):
resp = client.get("/")
assert resp.status_code == status.HTTP_200_OK
assert resp.json() == {"status": "ok"}
def test_authenticated_route_requires_auth(client):
resp = client.get("/authenticated-route")
assert resp.status_code in (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN)
@pytest.mark.asyncio
async def test_on_after_request_verify_enqueues_email(monkeypatch):

View File

@@ -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

View File

@@ -0,0 +1,25 @@
{{- if .Values.cron.enabled }}
apiVersion: batch/v1
kind: CronJob
metadata:
name: cronjob
spec:
schedule: {{ .Values.cron.schedule | quote }}
concurrencyPolicy: {{ .Values.cron.concurrencyPolicy | quote }}
jobTemplate:
spec:
template:
spec:
containers:
- name: cronjob
image: curlimages/curl:latest
imagePullPolicy: IfNotPresent
args:
- -sS
- -o
- /dev/null
- -w
- "%{http_code}"
- {{ printf "%s://%s.%s.svc.cluster.local%s" .Values.cron.scheme .Values.app.name .Release.Namespace .Values.cron.endpoint | quote }}
restartPolicy: OnFailure
{{- end }}

View File

@@ -19,3 +19,11 @@ stringData:
RABBITMQ_USERNAME: {{ .Values.rabbitmq.username | quote }}
SENTRY_DSN: {{ .Values.sentry_dsn | quote }}
DB_ENCRYPTION_KEY: {{ required "Set .Values.database.encryptionSecret" .Values.database.encryptionSecret | quote }}
SMTP_HOST: {{ .Values.smtp.host | default "" | quote }}
SMTP_PORT: {{ .Values.smtp.port | default 587 | quote }}
SMTP_USERNAME: {{ .Values.smtp.username | default "" | quote }}
SMTP_PASSWORD: {{ .Values.smtp.password | default "" | quote }}
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 }}

View File

@@ -85,3 +85,40 @@ spec:
secretKeyRef:
name: prod
key: DB_ENCRYPTION_KEY
- name: SMTP_HOST
valueFrom:
secretKeyRef:
name: prod
key: SMTP_HOST
- name: SMTP_PORT
valueFrom:
secretKeyRef:
name: prod
key: SMTP_PORT
- name: SMTP_USERNAME
valueFrom:
secretKeyRef:
name: prod
key: SMTP_USERNAME
- name: SMTP_PASSWORD
valueFrom:
secretKeyRef:
name: prod
key: SMTP_PASSWORD
- name: SMTP_USE_TLS
valueFrom:
secretKeyRef:
name: prod
key: SMTP_USE_TLS
- name: SMTP_USE_SSL
valueFrom:
secretKeyRef:
name: prod
key: SMTP_USE_SSL
- name: SMTP_FROM
valueFrom:
secretKeyRef:
name: prod
key: SMTP_FROM
- name: APP_POD_URL
value: {{ printf "http://%s.%s.svc.cluster.local" .Values.app.name .Release.Namespace | quote }}

View File

@@ -5,3 +5,6 @@ app:
worker:
replicas: 3
cron:
enabled: true

View File

@@ -13,6 +13,9 @@ deployment: ""
domain: ""
domain_scheme: ""
unirate:
key: ""
frontend_domain: ""
frontend_domain_scheme: ""
@@ -35,6 +38,23 @@ worker:
# Queue name for Celery worker and for CRD Queue
mailQueueName: "mail_queue"
cron:
enabled: false
schedule: "*/5 * * * *" # every 5 minutes
scheme: "http"
endpoint: "/_cron"
concurrencyPolicy: "Forbid"
smtp:
host:
port: 587
username: ""
password: ""
tls: false
ssl: false
from: ""
service:
port: 80

Some files were not shown because too many files have changed in this diff Show More