Compare commits

...

64 Commits

Author SHA1 Message Date
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
c864e753c9 feat(logs): add loki logging
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-10-30 17:39:27 +01:00
b4a453be04 feat(logs): add loki logging 2025-10-30 17:38:13 +01:00
d290664352 Merge pull request #42 from dat515-2025/merge/prometheus_metrics
feat(metrics): add basic prometheus metrics, cluster scraping
2025-10-30 15:09:35 +01:00
008f111fa7 feat(metrics): add basic prometheus metrics, cluster scraping 2025-10-30 15:05:31 +01:00
ece2c4d4c5 feat(metrics): add basic prometheus metrics, cluster scraping 2025-10-30 14:52:33 +01:00
2d0d309d2b feat(metrics): add basic prometheus metrics, cluster scraping 2025-10-30 14:42:11 +01:00
7f8dd2e846 feat(metrics): add basic prometheus metrics, cluster scraping 2025-10-30 14:31:09 +01:00
e0c18912f3 feat(metrics): add basic prometheus metrics, cluster scraping 2025-10-30 14:23:15 +01:00
99384aeb0a feat(metrics): add basic prometheus metrics, cluster scraping 2025-10-30 14:18:55 +01:00
912697b046 fix(relations): allow deleting transaction when relation exists 2025-10-30 13:49:29 +01:00
ribardej
356e1d868c fix(frontend): CNB API fix 2025-10-30 13:23:51 +01:00
ribardej
14397b8a25 Merge remote-tracking branch 'origin/main'
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-10-30 12:48:45 +01:00
ribardej
5671f97120 feat(docs): meeting 2025-10-30 12:48:37 +01:00
Dejan Ribarovski
b02c502b4f Merge pull request #41 from dat515-2025/40-fix-ui-layout-and-add-exchange-rates-from-cnb-api
feat(frontend): added CNB API and moved management into a new tab
2025-10-30 12:46:58 +01:00
ff118603db fix(scraper): add negative amounts 2025-10-30 12:36:17 +01:00
ribardej
3ee2abefd0 feat(frontend): added CNB API and moved management into a new tab 2025-10-30 12:35:38 +01:00
ribardej
4a8edf6eb8 feat(frontend): added CNB API and moved management into a new tab 2025-10-30 12:30:35 +01:00
a97f0f7097 Merge pull request #39 from dat515-2025/merge/csas_scraping
feat(worker): add transaction saving to db
2025-10-30 12:16:39 +01:00
ribardej
c74462b82f Merge remote-tracking branch 'origin/main' 2025-10-29 21:29:47 +01:00
Dejan Ribarovski
a96514f795 Merge pull request #38 (log in after expiration and added a few more tests)
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-10-29 20:10:48 +01:00
ribardej
4c9879cebf fix(tests): finally fixed the test DB deployment :} 2025-10-29 20:04:50 +01:00
ribardej
d9c562f867 fix(tests): fixed testing DB deployment v9 :O 2025-10-29 20:01:21 +01:00
ribardej
dddca9d805 fix(tests): fixed testing DB deployment v8 :D 2025-10-29 19:57:20 +01:00
ribardej
483a859b4b fix(tests): fixed testing DB deployment v7 2025-10-29 19:53:26 +01:00
ribardej
7529c9b265 fix(tests): fixed testing DB deployment v6 2025-10-29 19:45:08 +01:00
d6a913a896 feat(worker): add transaction saving to db 2025-10-29 18:11:53 +01:00
ribardej
2ca8a3b576 Merge remote-tracking branch 'origin/main' into 33-frontend-looks-like-logged-in-even-after-token-expires
# Conflicts:
#	.github/workflows/run-tests.yml
2025-10-29 14:54:01 +01:00
ribardej
52f6bd6a53 fix(tests): fixed testing DB deployment v5 2025-10-29 14:43:26 +01:00
d8ea25943c feat(code): remove sentry debug endpoint 2025-10-29 14:32:25 +01:00
06dcccb321 fix(tests): add missing dependencies 2025-10-29 14:28:25 +01:00
e916a57e4e fix(tests): move requirements.txt 2025-10-29 14:25:18 +01:00
7d2e94e683 feat(database): add encryption key 2025-10-29 14:23:14 +01:00
ribardej
55f8e38376 fix(tests): fixed testing DB deployment v4 2025-10-29 14:20:20 +01:00
3348e0a035 feat(database): encrypt transactions data 2025-10-29 14:17:53 +01:00
ribardej
542b05d541 fix(tests): fixed testing DB deployment v3 2025-10-29 14:11:43 +01:00
ribardej
65957d78ec fix(tests): fixed testing DB deployment 2025-10-29 14:07:06 +01:00
ribardej
edb4dfd147 fix(tests): fixed testing DB deployment 2025-10-29 13:50:04 +01:00
ribardej
cf1d520a30 feat(tests): added testing DB 2025-10-29 13:42:01 +01:00
ribardej
4aa299d77d feat(docs): went through the checklist.md 2025-10-29 13:18:14 +01:00
ribardej
e460f647b2 Merge remote-tracking branch 'origin/33-frontend-looks-like-logged-in-even-after-token-expires' into 33-frontend-looks-like-logged-in-even-after-token-expires 2025-10-23 19:16:36 +02:00
ribardej
b0cd7030d8 fix(backend): adressed copilot review 2025-10-23 19:16:14 +02:00
Dejan Ribarovski
eb7b2290b8 Update 7project/backend/app/core/security.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-23 19:14:22 +02:00
ribardej
584c090b80 fix(backend): implemented jwt token invalidation so users cannot use it after expiry 2025-10-23 19:04:48 +02:00
Dejan Ribarovski
4f6d46ba7e Merge pull request #37 from dat515-2025/36-add-mock-databases-or-services-to-fetch-mocked-transactions
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
Run Python Tests / build-and-test (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(frontend): Added Mock Bank connection
2025-10-23 13:02:32 +02:00
Dejan Ribarovski
9fc8601e4d Update 7project/frontend/src/pages/Dashboard.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-23 12:53:39 +02:00
Dejan Ribarovski
e488771cc7 Update 7project/frontend/src/pages/MockBankModal.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-23 12:53:04 +02:00
ribardej
77992bab17 feat(docs): meeting update 2025-10-23 12:47:27 +02:00
ribardej
6972a03090 feat(frontend): Added Mock Bank connection 2025-10-23 12:08:28 +02:00
47 changed files with 1905 additions and 464 deletions

View File

@@ -12,25 +12,7 @@ jobs:
test: test:
name: Run Python Tests name: Run Python Tests
if: github.event.action != 'closed' if: github.event.action != 'closed'
runs-on: ubuntu-latest uses: ./.github/workflows/run-tests.yml
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run tests with pytest
run: pytest
working-directory: ./7project/backend
build: build:
if: github.event.action != 'closed' if: github.event.action != 'closed'
@@ -118,7 +100,9 @@ jobs:
--set frontend_domain_scheme="$FRONTEND_DOMAIN_SCHEME" \ --set frontend_domain_scheme="$FRONTEND_DOMAIN_SCHEME" \
--set image.digest="$DIGEST" \ --set image.digest="$DIGEST" \
--set-string rabbitmq.password="$RABBITMQ_PASSWORD" \ --set-string rabbitmq.password="$RABBITMQ_PASSWORD" \
--set-string database.password="$DB_PASSWORD" --set-string database.password="$DB_PASSWORD" \
--set-string database.encryptionSecret="$PR" \
--set-string app.name="finance-tracker-pr-$PR"
- name: Post preview URLs as PR comment - name: Post preview URLs as PR comment
uses: actions/github-script@v7 uses: actions/github-script@v7

View File

@@ -23,26 +23,7 @@ concurrency:
jobs: jobs:
test: test:
name: Run Python Tests name: Run Python Tests
if: github.event.action != 'closed' uses: ./.github/workflows/run-tests.yml
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run tests with pytest
run: pytest
working-directory: ./7project/backend
build: build:
name: Build and push image (reusable) name: Build and push image (reusable)
@@ -129,4 +110,5 @@ jobs:
--set-string oauth.mojeid.clientSecret="$MOJEID_CLIENT_SECRET" \ --set-string oauth.mojeid.clientSecret="$MOJEID_CLIENT_SECRET" \
--set-string oauth.csas.clientId="$CSAS_CLIENT_ID" \ --set-string oauth.csas.clientId="$CSAS_CLIENT_ID" \
--set-string oauth.csas.clientSecret="$CSAS_CLIENT_SECRET" \ --set-string oauth.csas.clientSecret="$CSAS_CLIENT_SECRET" \
--set-string sentry_dsn="$SENTRY_DSN" \ --set-string sentry_dsn="$SENTRY_DSN" \
--set-string database.encryptionSecret="${{ secrets.PROD_DB_ENCRYPTION_KEY }}"

View File

@@ -2,54 +2,60 @@ name: Run Python Tests
permissions: permissions:
contents: read contents: read
# -----------------
# --- Triggers ----
# -----------------
# This section defines when the workflow will run.
on: on:
# Run on every push to the 'main' branch workflow_call:
push:
branches: [ "main", "30-create-tests-and-set-up-a-github-pipeline" ]
# Also run on every pull request that targets the 'main' branch
pull_request:
branches: [ "main" ]
# -----------------
# ------ Jobs -----
# -----------------
# A workflow is made up of one or more jobs that can run in parallel or sequentially.
jobs: jobs:
# A descriptive name for your job
build-and-test: build-and-test:
# Specifies the virtual machine to run the job on. 'ubuntu-latest' is a common and cost-effective choice.
runs-on: ubuntu-latest runs-on: ubuntu-latest
# ----------------- services:
# ----- Steps ----- mariadb:
# ----------------- image: mariadb:11.4
# A sequence of tasks that will be executed as part of the job. env:
MARIADB_ROOT_PASSWORD: rootpw
MARIADB_DATABASE: group_project
MARIADB_USER: appuser
MARIADB_PASSWORD: apppass
ports:
- 3306:3306
options: >-
--health-cmd="mariadb-admin ping -h 127.0.0.1 -u root -prootpw --silent"
--health-interval=5s
--health-timeout=2s
--health-retries=20
env:
MARIADB_HOST: 127.0.0.1
MARIADB_PORT: "3306"
MARIADB_DB: group_project
MARIADB_USER: appuser
MARIADB_PASSWORD: apppass
steps: steps:
# Step 1: Check out your repository's code
# This action allows the workflow to access your code.
- name: Check out repository code - name: Check out repository code
uses: actions/checkout@v4 uses: actions/checkout@v4
# Step 2: Set up the Python environment
# This action installs a specific version of Python on the runner.
- name: Set up Python 3.11 - name: Set up Python 3.11
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: '3.11' # Use the Python version that matches your project python-version: '3.11'
- 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
# Step 3: Install project dependencies
# Runs shell commands to install the libraries listed in your requirements.txt.
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -r requirements.txt pip install -r ./7project/backend/requirements.txt
# Step 4: Run your tests! - name: Run Alembic migrations
# Executes the pytest command to run your test suite. run: |
alembic upgrade head
working-directory: ./7project/backend
- name: Run tests with pytest - name: Run tests with pytest
run: pytest run: pytest
working-directory: ./7project/backend working-directory: ./7project/backend

View File

@@ -25,7 +25,8 @@ if not DATABASE_URL:
SYNC_DATABASE_URL = DATABASE_URL.replace("+asyncmy", "+pymysql") SYNC_DATABASE_URL = DATABASE_URL.replace("+asyncmy", "+pymysql")
ssl_enabled = os.getenv("MARIADB_HOST", "localhost") != "localhost" 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 {} connect_args = {"ssl": {"ssl": True}} if ssl_enabled else {}
def run_migrations_offline() -> None: def run_migrations_offline() -> None:

View File

@@ -0,0 +1,47 @@
"""Add encrypted type
Revision ID: 46b9e702e83f
Revises: 1f2a3c4d5e6f
Create Date: 2025-10-29 13:26:24.568523
"""
from typing import Sequence, Union
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = '46b9e702e83f'
down_revision: Union[str, Sequence[str], None] = '1f2a3c4d5e6f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('transaction', 'amount',
existing_type=mysql.FLOAT(),
type_=sqlalchemy_utils.types.encrypted.encrypted_type.EncryptedType(),
existing_nullable=False)
op.alter_column('transaction', 'description',
existing_type=mysql.VARCHAR(length=255),
type_=sqlalchemy_utils.types.encrypted.encrypted_type.EncryptedType(),
existing_nullable=True)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('transaction', 'description',
existing_type=sqlalchemy_utils.types.encrypted.encrypted_type.EncryptedType(),
type_=mysql.VARCHAR(length=255),
existing_nullable=True)
op.alter_column('transaction', 'amount',
existing_type=sqlalchemy_utils.types.encrypted.encrypted_type.EncryptedType(),
type_=mysql.FLOAT(),
existing_nullable=False)
# ### end Alembic commands ###

View File

@@ -0,0 +1,46 @@
"""Cascade categories
Revision ID: 59cebf320c4a
Revises: 46b9e702e83f
Create Date: 2025-10-30 13:42:44.555284
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = '59cebf320c4a'
down_revision: Union[str, Sequence[str], None] = '46b9e702e83f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('category_transaction', sa.Column('category_id', sa.Integer(), nullable=False))
op.add_column('category_transaction', sa.Column('transaction_id', sa.Integer(), nullable=False))
op.drop_constraint(op.f('category_transaction_ibfk_2'), 'category_transaction', type_='foreignkey')
op.drop_constraint(op.f('category_transaction_ibfk_1'), 'category_transaction', type_='foreignkey')
op.create_foreign_key(None, 'category_transaction', 'transaction', ['transaction_id'], ['id'], ondelete='CASCADE')
op.create_foreign_key(None, 'category_transaction', 'categories', ['category_id'], ['id'], ondelete='CASCADE')
op.drop_column('category_transaction', 'id_category')
op.drop_column('category_transaction', 'id_transaction')
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('category_transaction', sa.Column('id_transaction', mysql.INTEGER(display_width=11), autoincrement=False, nullable=True))
op.add_column('category_transaction', sa.Column('id_category', mysql.INTEGER(display_width=11), autoincrement=False, nullable=True))
op.drop_constraint(None, 'category_transaction', type_='foreignkey')
op.drop_constraint(None, 'category_transaction', type_='foreignkey')
op.create_foreign_key(op.f('category_transaction_ibfk_1'), 'category_transaction', 'categories', ['id_category'], ['id'])
op.create_foreign_key(op.f('category_transaction_ibfk_2'), 'category_transaction', 'transaction', ['id_transaction'], ['id'])
op.drop_column('category_transaction', 'transaction_id')
op.drop_column('category_transaction', 'category_id')
# ### end Alembic commands ###

View File

@@ -24,6 +24,23 @@ async def delete_me(
await user_manager.delete(user) await user_manager.delete(user)
# Keep existing paths as-is under /auth/* and /users/* # Keep existing paths as-is under /auth/* and /users/*
from fastapi import Request, Response
from app.core.security import revoke_token, extract_bearer_token
@router.post(
"/auth/jwt/logout",
status_code=status.HTTP_204_NO_CONTENT,
tags=["auth"],
summary="Log out and revoke current token",
)
async def custom_logout(request: Request) -> Response:
"""Revoke the current bearer token so it cannot be used anymore."""
token = extract_bearer_token(request)
if token:
revoke_token(token)
return Response(status_code=status.HTTP_204_NO_CONTENT)
router.include_router( router.include_router(
fastapi_users.get_auth_router(auth_backend), prefix="/auth/jwt", tags=["auth"] fastapi_users.get_auth_router(auth_backend), prefix="/auth/jwt", tags=["auth"]
) )

View File

@@ -1,11 +1,16 @@
import logging import logging
import os import os
import sys
from datetime import datetime from datetime import datetime
from pythonjsonlogger import jsonlogger
from fastapi import Depends, FastAPI from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from prometheus_fastapi_instrumentator import Instrumentator, metrics
from starlette.requests import Request from starlette.requests import Request
from app.services.prometheus import number_of_users, number_of_transactions
from app.services import bank_scraper from app.services import bank_scraper
from app.workers.celery_tasks import load_transactions, load_all_transactions from app.workers.celery_tasks import load_transactions, load_all_transactions
from app.models.user import User, OAuthAccount from app.models.user import User, OAuthAccount
@@ -15,8 +20,10 @@ from app.api.auth import router as auth_router
from app.api.csas import router as csas_router from app.api.csas import router as csas_router
from app.api.categories import router as categories_router from app.api.categories import router as categories_router
from app.api.transactions import router as transactions_router from app.api.transactions import router as transactions_router
from app.services.user_service import auth_backend, current_active_verified_user, fastapi_users, get_oauth_provider, UserManager, get_jwt_strategy 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
from app.services.user_service import SECRET
from fastapi import FastAPI from fastapi import FastAPI
import sentry_sdk import sentry_sdk
@@ -29,7 +36,6 @@ sentry_sdk.init(
) )
fastApi = FastAPI() fastApi = FastAPI()
app = fastApi
# CORS for frontend dev server # CORS for frontend dev server
fastApi.add_middleware( fastApi.add_middleware(
@@ -44,11 +50,58 @@ fastApi.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
prometheus = Instrumentator().instrument(fastApi)
# Register custom metrics
prometheus.add(number_of_users()).add(number_of_transactions())
prometheus.expose(
fastApi,
endpoint="/metrics",
include_in_schema=True,
)
fastApi.include_router(auth_router) fastApi.include_router(auth_router)
fastApi.include_router(categories_router) fastApi.include_router(categories_router)
fastApi.include_router(transactions_router) fastApi.include_router(transactions_router)
logging.basicConfig(filename='app.log', level=logging.INFO, format='%(asctime)s %(message)s')
for h in list(logging.root.handlers):
logging.root.removeHandler(h)
_log_handler = logging.StreamHandler(sys.stdout)
_formatter = jsonlogger.JsonFormatter(
fmt='%(asctime)s %(levelname)s %(name)s %(message)s %(pathname)s %(lineno)d %(process)d %(thread)d'
)
_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]
_logger.propagate = True
@fastApi.middleware("http")
async def auth_guard(request: Request, call_next):
# Enforce revoked/expired JWTs are rejected globally
token = extract_bearer_token(request)
if token:
from fastapi import Response, status as _status
# Deny if token is revoked
if is_token_revoked(token):
return Response(status_code=_status.HTTP_401_UNAUTHORIZED)
# Deny if token is expired or invalid
try:
decode_and_verify_jwt(token, SECRET)
except Exception:
return Response(status_code=_status.HTTP_401_UNAUTHORIZED)
return await call_next(request)
@fastApi.middleware("http") @fastApi.middleware("http")
async def log_traffic(request: Request, call_next): async def log_traffic(request: Request, call_next):
start_time = datetime.now() start_time = datetime.now()
@@ -66,9 +119,10 @@ async def log_traffic(request: Request, call_next):
"process_time": process_time, "process_time": process_time,
"client_host": client_host "client_host": client_host
} }
logging.info(str(log_params)) logging.getLogger(__name__).info("http_request", extra=log_params)
return response return response
fastApi.include_router( fastApi.include_router(
fastapi_users.get_oauth_router( fastapi_users.get_oauth_router(
get_oauth_provider("MojeID"), get_oauth_provider("MojeID"),
@@ -95,6 +149,7 @@ fastApi.include_router(
fastApi.include_router(csas_router) fastApi.include_router(csas_router)
# Liveness/root endpoint # Liveness/root endpoint
@fastApi.get("/", include_in_schema=False) @fastApi.get("/", include_in_schema=False)
async def root(): async def root():
@@ -105,10 +160,6 @@ async def root():
async def authenticated_route(user: User = Depends(current_active_verified_user)): async def authenticated_route(user: User = Depends(current_active_verified_user)):
return {"message": f"Hello {user.email}!"} return {"message": f"Hello {user.email}!"}
@fastApi.get("/sentry-debug")
async def trigger_error():
division_by_zero = 1 / 0
@fastApi.get("/debug/scrape/csas/all", tags=["debug"]) @fastApi.get("/debug/scrape/csas/all", tags=["debug"])
async def debug_scrape_csas_all(): async def debug_scrape_csas_all():
@@ -117,8 +168,9 @@ async def debug_scrape_csas_all():
return {"status": "queued", "action": "csas_scrape_all", "task_id": getattr(task, 'id', None)} return {"status": "queued", "action": "csas_scrape_all", "task_id": getattr(task, 'id', None)}
@fastApi.post("/debug/scrape/csas/{user_id}", tags=["debug"]) @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)): 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) logging.info("[Debug] Queueing CSAS scrape for single user via HTTP endpoint (Celery) | user_id=%s", user_id)
task = load_transactions.delay(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)} return {"status": "queued", "action": "csas_scrape_single", "user_id": user_id,
"task_id": getattr(task, 'id', None)}

View File

@@ -19,7 +19,8 @@ from app.models.user import User
from app.models.transaction import Transaction from app.models.transaction import Transaction
from app.models.categories import Category from app.models.categories import Category
ssl_enabled = os.getenv("MARIADB_HOST", "localhost") != "localhost" 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 {} connect_args = {"ssl": {"ssl": True}} if ssl_enabled else {}
engine = create_async_engine( engine = create_async_engine(

View File

@@ -0,0 +1,52 @@
from typing import Optional
import re
import jwt
from fastapi import Request
# Simple in-memory revocation store for revoked JWT tokens.
#
# Limitations:
# - All revoked tokens will be lost if the process restarts (data loss on restart).
# - Not suitable for multi-instance deployments: the revocation list is not shared between instances.
# A token revoked in one instance will not be recognized as revoked in others.
#
# For production, use a persistent and shared store (e.g., Redis or a database).
_REVOKED_TOKENS: set[str] = set()
# Bearer token regex
_BEARER_RE = re.compile(r"^[Bb]earer\s+(.+)$")
def extract_bearer_token(request: Request) -> Optional[str]:
auth = request.headers.get("authorization")
if not auth:
return None
m = _BEARER_RE.match(auth)
if not m:
return None
return m.group(1).strip()
def revoke_token(token: str) -> None:
if token:
_REVOKED_TOKENS.add(token)
def is_token_revoked(token: str) -> bool:
return token in _REVOKED_TOKENS
def decode_and_verify_jwt(token: str, secret: str) -> dict:
"""
Decode the JWT using the shared secret, verifying expiration and signature.
Audience is not verified here to be compatible with fastapi-users default tokens.
Raises jwt.ExpiredSignatureError if expired.
Raises jwt.InvalidTokenError for other issues.
Returns the decoded payload dict on success.
"""
return jwt.decode(
token,
secret,
algorithms=["HS256"],
options={"verify_aud": False},
) # verify_exp is True by default

View File

@@ -7,8 +7,8 @@ from app.core.base import Base
association_table = Table( association_table = Table(
"category_transaction", "category_transaction",
Base.metadata, Base.metadata,
Column("id_category", Integer, ForeignKey("categories.id")), Column("category_id", Integer, ForeignKey("categories.id", ondelete="CASCADE"), primary_key=True),
Column("id_transaction", Integer, ForeignKey("transaction.id")) Column("transaction_id", Integer, ForeignKey("transaction.id", ondelete="CASCADE"), primary_key=True)
) )

View File

@@ -1,18 +1,24 @@
import os
from fastapi_users_db_sqlalchemy import GUID from fastapi_users_db_sqlalchemy import GUID
from sqlalchemy import Column, Integer, String, Float, ForeignKey, Date, func from sqlalchemy import Column, Integer, String, Float, ForeignKey, Date, func
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy_utils import EncryptedType
from sqlalchemy_utils.types.encrypted.encrypted_type import FernetEngine
from app.core.base import Base from app.core.base import Base
from app.models.categories import association_table from app.models.categories import association_table
SECRET_KEY = os.environ.get("DB_ENCRYPTION_KEY", "localdev")
class Transaction(Base): class Transaction(Base):
__tablename__ = "transaction" __tablename__ = "transaction"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
amount = Column(Float, nullable=False) amount = Column(EncryptedType(Float, SECRET_KEY, engine=FernetEngine), nullable=False)
description = Column(String(length=255), nullable=True) description = Column(EncryptedType(String(length=255), SECRET_KEY, engine=FernetEngine), nullable=True)
date = Column(Date, nullable=False, server_default=func.current_date()) date = Column(Date, nullable=False, server_default=func.current_date())
user_id = Column(GUID, ForeignKey("user.id"), nullable=False) user_id = Column(GUID, ForeignKey("user.id"), nullable=False)
# Relationship # Relationship
user = relationship("User", back_populates="transactions") user = relationship("User", back_populates="transactions")
categories = relationship("Category", secondary=association_table, back_populates="transactions") categories = relationship("Category", secondary=association_table, back_populates="transactions", passive_deletes=True)

View File

@@ -1,17 +1,18 @@
import json import json
import logging import logging
from os.path import dirname, join from os.path import dirname, join
from time import strptime
from uuid import UUID from uuid import UUID
import httpx import httpx
from sqlalchemy import select from sqlalchemy import select
from app.core.db import async_session_maker from app.core.db import async_session_maker
from app.models.transaction import Transaction
from app.models.user import User from app.models.user import User
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Reuse CSAS mTLS certs used by OAuth profile calls
OAUTH_DIR = join(dirname(__file__), "..", "oauth") OAUTH_DIR = join(dirname(__file__), "..", "oauth")
CERTS = ( CERTS = (
join(OAUTH_DIR, "public_key.pem"), join(OAUTH_DIR, "public_key.pem"),
@@ -20,10 +21,6 @@ CERTS = (
async def aload_ceska_sporitelna_transactions(user_id: str) -> None: async def aload_ceska_sporitelna_transactions(user_id: str) -> None:
"""
Async entry point to load Česká spořitelna transactions for a single user.
Validates the user_id and performs a minimal placeholder action.
"""
try: try:
uid = UUID(str(user_id)) uid = UUID(str(user_id))
except Exception: except Exception:
@@ -34,9 +31,6 @@ async def aload_ceska_sporitelna_transactions(user_id: str) -> None:
async def aload_all_ceska_sporitelna_transactions() -> None: async def aload_all_ceska_sporitelna_transactions() -> None:
"""
Async entry point to load Česká spořitelna transactions for all users.
"""
async with async_session_maker() as session: async with async_session_maker() as session:
result = await session.execute(select(User)) result = await session.execute(select(User))
users = result.unique().scalars().all() users = result.unique().scalars().all()
@@ -54,7 +48,7 @@ async def aload_all_ceska_sporitelna_transactions() -> None:
async def _aload_ceska_sporitelna_transactions(user_id: UUID) -> None: async def _aload_ceska_sporitelna_transactions(user_id: UUID) -> None:
async with async_session_maker() as session: async with (async_session_maker() as session):
result = await session.execute(select(User).where(User.id == user_id)) result = await session.execute(select(User).where(User.id == user_id))
user: User = result.unique().scalar_one_or_none() user: User = result.unique().scalar_one_or_none()
if user is None: if user is None:
@@ -106,16 +100,25 @@ async def _aload_ceska_sporitelna_transactions(user_id: UUID) -> None:
if response.status_code != httpx.codes.OK: if response.status_code != httpx.codes.OK:
continue continue
# Placeholder: just print the account transactions
transactions = response.json()["transactions"] transactions = response.json()["transactions"]
pass
for transaction in transactions: for transaction in transactions:
#parse and store transaction to database description = transaction.get("entryDetails", {}).get("transactionDetails", {}).get(
#create Transaction object and save to DB "additionalRemittanceInformation")
#obj = 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":
amount = -abs(amount)
obj = Transaction(
amount=amount,
description=description,
date=date,
user_id=user_id,
)
session.add(obj)
await session.commit()
pass pass
pass pass

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

@@ -1,2 +1,5 @@
[tool.pytest.ini_options] [tool.pytest.ini_options]
pythonpath = "." pythonpath = "."
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"
asyncio_default_test_loop_scope = "session"

View File

@@ -38,6 +38,8 @@ MarkupSafe==3.0.2
multidict==6.6.4 multidict==6.6.4
packaging==25.0 packaging==25.0
pamqp==3.3.0 pamqp==3.3.0
prometheus-fastapi-instrumentator==7.1.0
prometheus_client==0.23.1
prompt_toolkit==3.0.52 prompt_toolkit==3.0.52
propcache==0.3.2 propcache==0.3.2
pwdlib==0.2.1 pwdlib==0.2.1
@@ -54,6 +56,7 @@ sentry-sdk==2.42.0
six==1.17.0 six==1.17.0
sniffio==1.3.1 sniffio==1.3.1
SQLAlchemy==2.0.43 SQLAlchemy==2.0.43
SQLAlchemy-Utils==0.42.0
starlette==0.48.0 starlette==0.48.0
tomli==2.2.1 tomli==2.2.1
typing-inspection==0.4.1 typing-inspection==0.4.1
@@ -67,3 +70,4 @@ watchfiles==1.1.0
wcwidth==0.2.14 wcwidth==0.2.14
websockets==15.0.1 websockets==15.0.1
yarl==1.20.1 yarl==1.20.1
python-json-logger==2.0.7

View File

@@ -1,7 +1,9 @@
import sys import sys
import uuid
import types import types
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from httpx import AsyncClient, ASGITransport
# Stub sentry_sdk to avoid optional dependency issues during import of app # Stub sentry_sdk to avoid optional dependency issues during import of app
stub = types.ModuleType("sentry_sdk") stub = types.ModuleType("sentry_sdk")
@@ -20,3 +22,23 @@ def fastapi_app():
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def client(fastapi_app): def client(fastapi_app):
return TestClient(fastapi_app, raise_server_exceptions=True) return TestClient(fastapi_app, raise_server_exceptions=True)
@pytest.fixture(scope="function")
async def test_user(fastapi_app):
"""
Creates a new user asynchronously and returns their credentials.
Does NOT log them in.
Using AsyncClient with ASGITransport avoids event loop conflicts with DB connections.
"""
unique_email = f"testuser_{uuid.uuid4()}@example.com"
password = "a_strong_password"
user_payload = {"email": unique_email, "password": password}
transport = ASGITransport(app=fastapi_app, raise_app_exceptions=True)
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
response = await ac.post("/auth/register", json=user_payload)
assert response.status_code == 201
return {"username": unique_email, "password": password}

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

@@ -1,15 +0,0 @@
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)

View File

@@ -1,5 +1,6 @@
from fastapi import status from fastapi import status
import pytest import pytest
from httpx import AsyncClient, ASGITransport
def test_root_ok(client): def test_root_ok(client):
@@ -13,6 +14,157 @@ def test_authenticated_route_requires_auth(client):
assert resp.status_code in (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN) assert resp.status_code in (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN)
def test_sentry_debug_raises_exception(client): @pytest.mark.asyncio
with pytest.raises(ZeroDivisionError): async def test_create_and_get_category(fastapi_app, test_user):
client.get("/sentry-debug") # 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/999999/delete", headers=h)
assert r.status_code == status.HTTP_404_NOT_FOUND

View File

@@ -19,7 +19,7 @@ def test_get_oauth_provider_known_unknown():
def test_get_jwt_strategy_lifetime(): def test_get_jwt_strategy_lifetime():
strategy = user_service.get_jwt_strategy() strategy = user_service.get_jwt_strategy()
assert strategy is not None assert strategy is not None
# Basic smoke check: strategy has a lifetime set to 3600 # Basic smoke check: strategy has a lifetime set to 604800
assert getattr(strategy, "lifetime_seconds", None) in (604800,) assert getattr(strategy, "lifetime_seconds", None) in (604800,)

View File

@@ -8,10 +8,12 @@ spec:
selector: selector:
matchLabels: matchLabels:
app: {{ .Values.app.name }} app: {{ .Values.app.name }}
endpoint: metrics
template: template:
metadata: metadata:
labels: labels:
app: {{ .Values.app.name }} app: {{ .Values.app.name }}
endpoint: metrics
spec: spec:
containers: containers:
- name: {{ .Values.app.name }} - name: {{ .Values.app.name }}
@@ -101,6 +103,11 @@ spec:
secretKeyRef: secretKeyRef:
name: prod name: prod
key: SENTRY_DSN key: SENTRY_DSN
- name: DB_ENCRYPTION_KEY
valueFrom:
secretKeyRef:
name: prod
key: DB_ENCRYPTION_KEY
livenessProbe: livenessProbe:
httpGet: httpGet:
path: / path: /

View File

@@ -0,0 +1,14 @@
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: fastapi-servicemonitor
labels:
release: kube-prometheus-stack
spec:
selector:
matchLabels:
app: {{ .Values.app.name }}
endpoints:
- port: http
path: /metrics
interval: 15s

View File

@@ -18,3 +18,4 @@ stringData:
RABBITMQ_PASSWORD: {{ .Values.rabbitmq.password | default "" | quote }} RABBITMQ_PASSWORD: {{ .Values.rabbitmq.password | default "" | quote }}
RABBITMQ_USERNAME: {{ .Values.rabbitmq.username | quote }} RABBITMQ_USERNAME: {{ .Values.rabbitmq.username | quote }}
SENTRY_DSN: {{ .Values.sentry_dsn | quote }} SENTRY_DSN: {{ .Values.sentry_dsn | quote }}
DB_ENCRYPTION_KEY: {{ required "Set .Values.database.encryptionSecret" .Values.database.encryptionSecret | quote }}

View File

@@ -2,9 +2,12 @@ apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: {{ .Values.app.name }} name: {{ .Values.app.name }}
labels:
app: {{ .Values.app.name }}
spec: spec:
ports: ports:
- port: {{ .Values.service.port }} - name: http
port: {{ .Values.service.port }}
targetPort: {{ .Values.app.port }} targetPort: {{ .Values.app.port }}
selector: selector:
app: {{ .Values.app.name }} app: {{ .Values.app.name }}

View File

@@ -20,7 +20,7 @@ spec:
securityContext: securityContext:
allowPrivilegeEscalation: false allowPrivilegeEscalation: false
capabilities: capabilities:
drop: ["ALL"] drop: [ "ALL" ]
command: command:
- celery - celery
- -A - -A
@@ -80,3 +80,8 @@ spec:
secretKeyRef: secretKeyRef:
name: prod name: prod
key: CSAS_CLIENT_SECRET key: CSAS_CLIENT_SECRET
- name: DB_ENCRYPTION_KEY
valueFrom:
secretKeyRef:
name: prod
key: DB_ENCRYPTION_KEY

View File

@@ -75,3 +75,4 @@ database:
userName: app-demo-user userName: app-demo-user
secretName: app-demo-database-secret secretName: app-demo-database-secret
password: "" password: ""
encryptionSecret: ""

View File

@@ -7,64 +7,64 @@ Focus on areas that align with your project goals and interests.
The core deliverables are required. The core deliverables are required.
This means that you must get at least 2 points for each item in this category. This means that you must get at least 2 points for each item in this category.
| **Category** | **Item** | **Max Points** | **Points** | | **Category** | **Item** | **Max Points** | **Points** |
| -------------------------------- | --------------------------------------- | -------------- | ---------------- | |----------------------------------| --------------------------------------- | -------------- |-------------------------------------------------|
| **Core Deliverables (Required)** | | | | | **Core Deliverables (Required)** | | | |
| Codebase & Organization | Well-organized project structure | 5 | | | Codebase & Organization | Well-organized project structure | 5 | 5 |
| | Clean, readable code | 5 | | | | Clean, readable code | 5 | 4 |
| | Use planning tool (e.g., GitHub issues) | 5 | | | | Use planning tool (e.g., GitHub issues) | 5 | 4 |
| | Proper version control usage | 5 | | | | Proper version control usage | 5 | 5 |
| | Complete source code | 5 | | | 23 | Complete source code | 5 | 5 |
| Documentation | Comprehensive reproducibility report | 10 | | | Documentation | Comprehensive reproducibility report | 10 | 4-5 |
| | Updated design document | 5 | | | | Updated design document | 5 | 2 |
| | Clear build/deployment instructions | 5 | | | | Clear build/deployment instructions | 5 | 2 |
| | Troubleshooting guide | 5 | | | | Troubleshooting guide | 5 | 1 |
| | Completed self-assessment table | 5 | | | | Completed self-assessment table | 5 | 2 |
| | Hour sheets for all members | 5 | | | 14 | Hour sheets for all members | 5 | 3 |
| Presentation Video | Project demonstration | 5 | | | Presentation Video | Project demonstration | 5 | 0 |
| | Code walk-through | 5 | | | | Code walk-through | 5 | 0 |
| | Deployment showcase | 5 | | | 0 | Deployment showcase | 5 | 0 |
| **Technical Implementation** | | | | | **Technical Implementation** | | | |
| Application Functionality | Basic functionality works | 10 | | | Application Functionality | Basic functionality works | 10 | 8 |
| | Advanced features implemented | 10 | | | | Advanced features implemented | 10 | 0 |
| | Error handling & robustness | 10 | | | | Error handling & robustness | 10 | 4 |
| | User-friendly interface | 5 | | | 16 | User-friendly interface | 5 | 4 |
| Backend & Architecture | Stateless web server | 5 | | | Backend & Architecture | Stateless web server | 5 | 5 |
| | Stateful application | 10 | | | | Stateful application | 10 | ? WHAT DOES THIS MEAN |
| | Database integration | 10 | | | | Database integration | 10 | 10 |
| | API design | 5 | | | | API design | 5 | 5 |
| | Microservices architecture | 10 | | | 20 | Microservices architecture | 10 | 0 |
| Cloud Integration | Basic cloud deployment | 10 | | | Cloud Integration | Basic cloud deployment | 10 | 10 |
| | Cloud APIs usage | 10 | | | | Cloud APIs usage | 10 | ? WHAT DOES THIS MEAN |
| | Serverless components | 10 | | | | Serverless components | 10 | 0 |
| | Advanced cloud services | 5 | | | 10 | Advanced cloud services | 5 | 0 |
| **DevOps & Deployment** | | | | | **DevOps & Deployment** | | | |
| Containerization | Basic Dockerfile | 5 | | | Containerization | Basic Dockerfile | 5 | 5 |
| | Optimized Dockerfile | 5 | | | | Optimized Dockerfile | 5 | 0 |
| | Docker Compose | 5 | | | | Docker Compose | 5 | 5 - dev only |
| | Persistent storage | 5 | | | 15 | Persistent storage | 5 | 5 |
| Deployment & Scaling | Manual deployment | 5 | | | Deployment & Scaling | Manual deployment | 5 | 5 |
| | Automated deployment | 5 | | | | Automated deployment | 5 | 5 |
| | Multiple replicas | 5 | | | | Multiple replicas | 5 | 5 |
| | Kubernetes deployment | 10 | | | 20 | Kubernetes deployment | 10 | 10 |
| **Quality Assurance** | | | | | **Quality Assurance** | | | |
| Testing | Unit tests | 5 | | | Testing | Unit tests | 5 | 2 |
| | Integration tests | 5 | | | | Integration tests | 5 | 2 |
| | End-to-end tests | 5 | | | | End-to-end tests | 5 | 5 |
| | Performance testing | 5 | | | 9 | Performance testing | 5 | 0 |
| Monitoring & Operations | Health checks | 5 | | | Monitoring & Operations | Health checks | 5 | 5 |
| | Logging | 5 | | | | Logging | 5 | 2 - only to terminal add logstash |
| | Metrics/Monitoring | 5 | | | 9 | Metrics/Monitoring | 5 | 2 - only DB, need to create Prometheus endpoint |
| Security | HTTPS/TLS | 5 | | | Security | HTTPS/TLS | 5 | 5 |
| | Authentication | 5 | | | | Authentication | 5 | 5 |
| | Authorization | 5 | | | 15 | Authorization | 5 | 5 |
| **Innovation & Excellence** | | | | | **Innovation & Excellence** | | | |
| Advanced Features and | AI/ML Integration | 10 | | | Advanced Features and | AI/ML Integration | 10 | 0 |
| Technical Excellence | Real-time features | 10 | | | Technical Excellence | Real-time features | 10 | 0 |
| | Creative problem solving | 10 | | | | Creative problem solving | 10 | ? |
| | Performance optimization | 5 | | | | Performance optimization | 5 | 2 |
| | Exceptional user experience | 5 | | | 2 | Exceptional user experience | 5 | 0 |
| **Total** | | **255** | **[Your Total]** | | **Total** | | **255** | **153** |
## Grading Scale ## Grading Scale

View File

@@ -19,6 +19,17 @@ export type Transaction = {
date?: string | null; // ISO date (YYYY-MM-DD) date?: string | null; // ISO date (YYYY-MM-DD)
}; };
export async function deleteTransaction(id: number): Promise<void> {
const res = await fetch(`${getBaseUrl()}/transactions/${id}/delete`, {
method: 'DELETE',
headers: getHeaders('none'),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || 'Failed to delete transaction');
}
}
function getBaseUrl() { function getBaseUrl() {
const base = BACKEND_URL?.replace(/\/$/, '') || ''; const base = BACKEND_URL?.replace(/\/$/, '') || '';
return base || ''; return base || '';

View File

@@ -13,9 +13,9 @@ export function applyTheme(theme: Theme) {
export function applyFontSize(size: FontSize) { export function applyFontSize(size: FontSize) {
const root = document.documentElement; const root = document.documentElement;
const map: Record<FontSize, string> = { const map: Record<FontSize, string> = {
small: '14px', small: '12px',
medium: '18px', medium: '15px',
large: '22px', large: '21px',
}; };
root.style.fontSize = map[size]; root.style.fontSize = map[size];
} }

View File

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

View File

@@ -24,8 +24,6 @@ a:hover {
body { body {
margin: 0; margin: 0;
display: flex;
place-items: center;
min-width: 320px; min-width: 320px;
min-height: 100vh; min-height: 100vh;
} }

View File

@@ -1,5 +1,6 @@
// src/BalanceChart.tsx // src/BalanceChart.tsx
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; import { useEffect, useRef, useState } from 'react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from 'recharts';
import { type BalancePoint } from '../api'; import { type BalancePoint } from '../api';
function formatAmount(n: number) { function formatAmount(n: number) {
@@ -10,37 +11,56 @@ function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); return new Date(dateStr).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
} }
export default function BalanceChart({ data }: { data: BalancePoint[] }) { type Props = { data: BalancePoint[]; pxPerPoint?: number };
export default function BalanceChart({ data, pxPerPoint = 40 }: Props) {
const wrapRef = useRef<HTMLDivElement | null>(null);
const [containerWidth, setContainerWidth] = useState(0);
useEffect(() => {
function measure() {
if (!wrapRef.current) return;
setContainerWidth(wrapRef.current.clientWidth);
}
measure();
const obs = new ResizeObserver(measure);
if (wrapRef.current) obs.observe(wrapRef.current);
return () => obs.disconnect();
}, []);
if (data.length === 0) { if (data.length === 0) {
return <div>No data to display</div>; return <div>No data to display</div>;
} }
const desiredWidth = Math.max(containerWidth, Math.max(600, data.length * pxPerPoint));
return ( return (
<ResponsiveContainer width="100%" height={300}> <div ref={wrapRef} className="chart-scroll">
<LineChart <div className="chart-inner" style={{ minWidth: desiredWidth, paddingBottom: 8 }}>
data={data} <LineChart
// Increased 'left' margin to create more space for the Y-axis label and tick values width={desiredWidth}
margin={{ top: 5, right: 30, left: 50, bottom: 5 }} // <-- Change this line height={300}
> data={data}
<CartesianGrid strokeDasharray="3 3" /> margin={{ top: 5, right: 30, left: 50, bottom: 5 }}
<XAxis >
dataKey="date" <CartesianGrid strokeDasharray="3 3" />
tickFormatter={formatDate} <XAxis
label={{ value: 'Date', position: 'insideBottom', offset: -5 }} dataKey="date"
/> tickFormatter={formatDate}
<YAxis label={{ value: 'Date', position: 'insideBottom', offset: -5 }}
tickFormatter={(value) => formatAmount(value as number)} />
// Adjusted 'offset' for the Y-axis label. <YAxis
// A negative offset moves it further away from the axis. tickFormatter={(value) => formatAmount(value as number)}
label={{ value: 'Balance', angle: -90, position: 'insideLeft', offset: -30 }} // <-- Change this line label={{ value: 'Balance', angle: -90, position: 'insideLeft', offset: -30 }}
/> />
<Tooltip <Tooltip
labelFormatter={formatDate} labelFormatter={formatDate}
formatter={(value) => [formatAmount(value as number), 'Balance']} formatter={(value) => [formatAmount(value as number), 'Balance']}
/> />
<Legend /> <Legend />
<Line type="monotone" dataKey="balance" stroke="#3b82f6" strokeWidth={2} activeDot={{ r: 8 }} /> <Line type="monotone" dataKey="balance" stroke="#3b82f6" strokeWidth={2} activeDot={{ r: 8 }} />
</LineChart> </LineChart>
</ResponsiveContainer> </div>
</div>
); );
} }

View File

@@ -92,9 +92,13 @@ export default function CategoryPieCharts({ transactions, categories }: { transa
return ( return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '20px', justifyContent: 'center' }}> <div className="pie-grid" >
<SinglePieChart data={expensesData} title="Expenses by Category" /> <div className="pie-card">
<SinglePieChart data={earningsData} title="Earnings by Category" /> <SinglePieChart data={expensesData} title="Expenses by Category" />
</div>
<div className="pie-card">
<SinglePieChart data={earningsData} title="Earnings by Category" />
</div>
</div> </div>
); );
} }

View File

@@ -1,21 +1,162 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { type Category, type Transaction, type BalancePoint, createTransaction, getCategories, getTransactions, createCategory, updateTransaction, getBalanceSeries } from '../api'; import { type Category, type Transaction, type BalancePoint, deleteTransaction, getCategories, getTransactions, createTransaction, updateTransaction, getBalanceSeries } from '../api';
import AccountPage from './AccountPage'; import AccountPage from './AccountPage';
import AppearancePage from './AppearancePage'; import AppearancePage from './AppearancePage';
import BalanceChart from './BalanceChart'; import BalanceChart from './BalanceChart';
import ManualManagement from './ManualManagement';
import CategoryPieChart from './CategoryPieChart'; import CategoryPieChart from './CategoryPieChart';
import { BACKEND_URL } from '../config'; import MockBankModal, { type MockGenerationOptions } from './MockBankModal';
import { BACKEND_URL, VITE_UNIRATE_API_KEY } from '../config';
function formatAmount(n: number) { function formatAmount(n: number) {
return new Intl.NumberFormat(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n); return new Intl.NumberFormat(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n);
} }
//https://unirateapi.com/
// Define the structure for the rate data we care about
type RateData = {
currencyCode: string;
rate: number;
};
// The part of the API response structure we need
type UnirateApiResponse = {
base: string;
rates: { [key: string]: number };
// We'll also check for error formats just in case
message?: string;
error?: {
info: string;
};
};
// The currencies you want to display
const TARGET_CURRENCIES = ['EUR', 'USD', 'NOK'];
function CurrencyRates() {
const [rates, setRates] = useState<RateData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function fetchRates() {
setLoading(true);
setError(null);
const API_KEY = VITE_UNIRATE_API_KEY;
// We need to get the CZK rate as well, to use it for conversion
const allSymbols = [...TARGET_CURRENCIES, 'CZK'].join(',');
// We remove the `base` param, as the API seems to force base=USD
const UNIRATE_API_URL = `https://unirateapi.com/api/rates?api_key=${API_KEY}&symbols=${allSymbols}`;
try {
const res = await fetch(UNIRATE_API_URL);
const data: UnirateApiResponse = await res.json();
// --- THIS IS THE NEW, CORRECTED LOGIC ---
// 1. Check if the 'rates' object exists. If not, it's an error.
if (!data.rates) {
let errorMessage = data.message || (data.error ? data.error.info : 'Invalid API response');
throw new Error(errorMessage || 'Could not load rates');
}
// 2. Check that we got the base currency (USD) and our conversion currency (CZK)
if (data.base !== 'USD' || !data.rates.CZK) {
throw new Error('API response is missing required data for conversion (USD or CZK)');
}
// 3. Get our main conversion factor
const czkPerUsd = data.rates.CZK; // e.g., 23.0
// 4. Calculate the rates for our target currencies
const formattedRates = TARGET_CURRENCIES.map(code => {
const targetPerUsd = data.rates[code]; // e.g., 0.9 for EUR
// This calculates: (CZK per USD) / (TARGET per USD) = CZK per TARGET
// e.g. (23.0 CZK / 1 USD) / (0.9 EUR / 1 USD) = 25.55 CZK / 1 EUR
const rate = czkPerUsd / targetPerUsd;
return {
currencyCode: code,
rate: rate,
};
});
setRates(formattedRates);
} 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>
)}
<a
href="https://unirateapi.com"
target="_blank"
rel="noopener noreferrer"
style={{
display: 'block',
marginTop: '1rem',
fontSize: '0.8em',
color: '#8a91b4', // Muted color
textDecoration: 'none'
}}
>
Exchange Rates By UniRateAPI
</a>
</div>
);
}
export default function Dashboard({ onLogout }: { onLogout: () => void }) { export default function Dashboard({ onLogout }: { onLogout: () => void }) {
const [current, setCurrent] = useState<'home' | 'account' | 'appearance'>('home'); const [current, setCurrent] = useState<'home' | 'manual' | 'account' | 'appearance'>('home');
const [transactions, setTransactions] = useState<Transaction[]>([]); const [transactions, setTransactions] = useState<Transaction[]>([]);
const [categories, setCategories] = useState<Category[]>([]); const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isMockModalOpen, setMockModalOpen] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
// Start CSAS (George) OAuth after login // Start CSAS (George) OAuth after login
async function startOauthCsas() { async function startOauthCsas() {
@@ -38,11 +179,6 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
} }
} }
// New transaction form state
const [amount, setAmount] = useState<string>('');
const [description, setDescription] = useState('');
const [selectedCategoryId, setSelectedCategoryId] = useState<number | ''>('');
// Filters // Filters
const [minAmount, setMinAmount] = useState<string>(''); const [minAmount, setMinAmount] = useState<string>('');
const [maxAmount, setMaxAmount] = useState<string>(''); const [maxAmount, setMaxAmount] = useState<string>('');
@@ -60,16 +196,19 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
// Balance chart series for current date filter // Balance chart series for current date filter
const [balanceSeries, setBalanceSeries] = useState<BalancePoint[]>([]); const [balanceSeries, setBalanceSeries] = useState<BalancePoint[]>([]);
// Category creation form // Manual forms moved to ManualManagement page
const [newCatName, setNewCatName] = useState('');
const [newCatDesc, setNewCatDesc] = useState('');
// New transaction date // Inline edit state for transaction editing
const [txDate, setTxDate] = useState<string>('');
// Inline edit state for transaction categories
const [editingTxId, setEditingTxId] = useState<number | null>(null); const [editingTxId, setEditingTxId] = useState<number | null>(null);
const [editingCategoryIds, setEditingCategoryIds] = useState<number[]>([]); const [editingCategoryIds, setEditingCategoryIds] = useState<number[]>([]);
const [editingAmount, setEditingAmount] = useState<string>('');
const [editingDescription, setEditingDescription] = useState<string>('');
const [editingDate, setEditingDate] = useState<string>(''); // YYYY-MM-DD
// Sidebar toggle for mobile
const [sidebarOpen, setSidebarOpen] = useState(false);
async function loadAll() { async function loadAll() {
setLoading(true); setLoading(true);
@@ -92,6 +231,50 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
} }
} }
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]); useEffect(() => { loadAll(); }, [startDate, endDate]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
@@ -120,56 +303,79 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
function categoryNameById(id: number) { return categories.find(c => c.id === id)?.name || `#${id}`; } function categoryNameById(id: number) { return categories.find(c => c.id === id)?.name || `#${id}`; }
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!amount) return;
const payload = {
amount: Number(amount),
description: description || undefined,
category_ids: selectedCategoryId !== '' ? [Number(selectedCategoryId)] : undefined,
date: txDate || undefined,
};
try {
const created = await createTransaction(payload);
setTransactions(prev => [created, ...prev]);
setAmount(''); setDescription(''); setSelectedCategoryId(''); setTxDate('');
} catch (err: any) {
alert(err?.message || 'Failed to create transaction');
}
}
function beginEditCategories(t: Transaction) { function beginEditTransaction(t: Transaction) {
setEditingTxId(t.id); setEditingTxId(t.id);
setEditingCategoryIds([...(t.category_ids || [])]); setEditingCategoryIds([...(t.category_ids || [])]);
setEditingAmount(String(t.amount));
setEditingDescription(t.description || '');
setEditingDate(t.date || '');
} }
function cancelEditCategories() { function cancelEditTransaction() {
setEditingTxId(null); setEditingTxId(null);
setEditingCategoryIds([]); setEditingCategoryIds([]);
setEditingAmount('');
setEditingDescription('');
setEditingDate('');
} }
async function saveEditCategories() { async function saveEditTransaction() {
if (editingTxId == null) return; if (editingTxId == null) return;
const amountNum = Number(editingAmount);
if (Number.isNaN(amountNum)) {
alert('Amount must be a number.');
return;
}
try { try {
const updated = await updateTransaction(editingTxId, { category_ids: editingCategoryIds }); const updated = await updateTransaction(editingTxId, {
amount: amountNum,
description: editingDescription,
date: editingDate || undefined,
category_ids: editingCategoryIds,
});
setTransactions(prev => prev.map(p => (p.id === updated.id ? updated : p))); setTransactions(prev => prev.map(p => (p.id === updated.id ? updated : p)));
cancelEditCategories(); // Optionally refresh balance series to reflect changes immediately
try { setBalanceSeries(await getBalanceSeries(startDate || undefined, endDate || undefined)); } catch {}
cancelEditTransaction();
} catch (err: any) { } catch (err: any) {
alert(err?.message || 'Failed to update transaction categories'); alert(err?.message || 'Failed to update transaction');
}
}
async function handleDeleteTransaction(id: number) {
if (!confirm('Delete this transaction? This cannot be undone.')) return;
try {
await deleteTransaction(id);
setTransactions(prev => prev.filter(t => t.id !== id));
try { setBalanceSeries(await getBalanceSeries(startDate || undefined, endDate || undefined)); } catch {}
} catch (err: any) {
alert(err?.message || 'Failed to delete transaction');
} }
} }
return ( return (
<div className="app-layout"> <div className={`app-layout ${sidebarOpen ? 'sidebar-open' : ''}`}>
<aside className="sidebar"> <aside className="sidebar" style={{ display: 'flex', flexDirection: 'column' }}>
<div className="logo">7Project</div> <div>
<nav className="nav"> <div className="logo">Finance Tracker</div>
<button className={current === 'home' ? 'active' : ''} onClick={() => setCurrent('home')}>Home</button> <nav className="nav" onClick={() => setSidebarOpen(false)}>
<button className={current === 'account' ? 'active' : ''} onClick={() => setCurrent('account')}>Account</button> <button className={current === 'home' ? 'active' : ''} onClick={() => setCurrent('home')}>Home</button>
<button className={current === 'appearance' ? 'active' : ''} onClick={() => setCurrent('appearance')}>Appearance</button> <button className={current === 'manual' ? 'active' : ''} onClick={() => setCurrent('manual')}>Manual management</button>
</nav> <button className={current === 'account' ? 'active' : ''} onClick={() => setCurrent('account')}>Account</button>
<button className={current === 'appearance' ? 'active' : ''} onClick={() => setCurrent('appearance')}>Appearance</button>
</nav>
</div>
<CurrencyRates />
</aside> </aside>
<div className="content"> <div className="content">
<div className="topbar"> <div className="topbar">
<h2 style={{ margin: 0 }}>{current === 'home' ? 'Dashboard' : current === 'account' ? 'Account' : 'Appearance'}</h2> <button
className="icon-btn hamburger"
aria-label="Open menu"
aria-expanded={sidebarOpen}
onClick={() => setSidebarOpen(true)}
></button>
<h2 style={{ margin: 0 }}>{current === 'home' ? 'Dashboard' : current === 'manual' ? 'Manual management' : current === 'account' ? 'Account' : 'Appearance'}</h2>
<div className="actions"> <div className="actions">
<span className="user muted">Signed in</span> <span className="user muted">Signed in</span>
<button className="btn" onClick={onLogout}>Logout</button> <button className="btn" onClick={onLogout}>Logout</button>
@@ -178,34 +384,19 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
<main className="page space-y"> <main className="page space-y">
{current === 'home' && ( {current === 'home' && (
<> <>
<section className="card"> <section className="card space-y">
<h3>Bank connections</h3> <h3>Bank connections</h3>
<p className="muted">Connect your CSAS (George) account.</p> <div className="connection-row">
<button className="btn primary" onClick={startOauthCsas}>Connect CSAS (George)</button> <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>
<section className="card">
<h3>Add Transaction</h3>
<form onSubmit={handleCreate} className="form-row">
<input className="input" type="number" step="0.01" placeholder="Amount" value={amount} onChange={(e) => setAmount(e.target.value)} required />
<input className="input" type="date" placeholder="Date (optional)" value={txDate} onChange={(e) => setTxDate(e.target.value)} />
<input className="input" type="text" placeholder="Description (optional)" value={description} onChange={(e) => setDescription(e.target.value)} />
<select className="input" value={selectedCategoryId} onChange={(e) => setSelectedCategoryId(e.target.value ? Number(e.target.value) : '')}>
<option value="">No category</option>
{categories.map(c => (<option key={c.id} value={c.id}>{c.name}</option>))}
</select>
<button className="btn primary" type="submit">Add</button>
</form>
</section>
<section className="card">
<h3>Categories</h3>
<form className="form-row" onSubmit={async (e) => { e.preventDefault(); if (!newCatName.trim()) return; try { const cat = await createCategory({ name: newCatName.trim(), description: newCatDesc || undefined }); setCategories(prev => [...prev, cat]); setNewCatName(''); setNewCatDesc(''); } catch (err: any) { alert(err?.message || 'Failed to create category'); } }}>
<input className="input" type="text" placeholder="New category name" value={newCatName} onChange={(e) => setNewCatName(e.target.value)} />
<input className="input" type="text" placeholder="Description (optional)" value={newCatDesc} onChange={(e) => setNewCatDesc(e.target.value)} />
<button className="btn primary" type="submit">Create category</button>
</form>
</section>
<section className="card"> <section className="card">
<h3>Filters</h3> <h3>Filters</h3>
@@ -264,41 +455,98 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
<button className="btn primary" disabled={page >= totalPages - 1} onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}>Next</button> <button className="btn primary" disabled={page >= totalPages - 1} onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}>Next</button>
</div> </div>
</div> </div>
<table className="table"> <table className="table responsive">
<thead> <thead>
<tr> <tr>
<th>ID</th>
<th>Date</th> <th>Date</th>
<th style={{ textAlign: 'right' }}>Amount</th> <th style={{ textAlign: 'right' }}>Amount</th>
<th>Description</th> <th>Description</th>
<th>Categories</th> <th>Categories</th>
<th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{visible.map(t => ( {visible.map(t => (
<tr key={t.id}> <tr key={t.id}>
<td>{t.id}</td> {/* Date cell */}
<td>{t.date || ''}</td> <td data-label="Date">
<td className="amount">{formatAmount(t.amount)}</td>
<td>{t.description || ''}</td>
<td>
{editingTxId === t.id ? ( {editingTxId === t.id ? (
<div className="space-y" style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <input
<select multiple className="input" value={editingCategoryIds.map(String)} onChange={(e) => { className="input"
const opts = Array.from(e.currentTarget.selectedOptions).map(o => Number(o.value)); type="date"
setEditingCategoryIds(opts); value={editingDate}
}}> onChange={(e) => setEditingDate(e.target.value)}
/>
) : (
t.date || ''
)}
</td>
{/* Amount cell */}
<td data-label="Amount" className="amount" style={{ textAlign: 'right' }}>
{editingTxId === t.id ? (
<input
className="input"
type="number"
step="0.01"
value={editingAmount}
onChange={(e) => setEditingAmount(e.target.value)}
style={{ textAlign: 'right' }}
/>
) : (
formatAmount(t.amount)
)}
</td>
{/* Description cell */}
<td data-label="Description">
{editingTxId === t.id ? (
<input
className="input"
type="text"
value={editingDescription}
onChange={(e) => setEditingDescription(e.target.value)}
/>
) : (
t.description || ''
)}
</td>
{/* Categories cell */}
<td data-label="Categories">
{editingTxId === t.id ? (
<div 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 => ( {categories.map(c => (
<option key={c.id} value={c.id}>{c.name}</option> <option key={c.id} value={c.id}>{c.name}</option>
))} ))}
</select> </select>
<button className="btn small" onClick={saveEditCategories}>Save</button>
<button className="btn small" onClick={cancelEditCategories}>Cancel</button>
</div> </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>
<span>{t.category_ids.map(id => categoryNameById(id)).join(', ') || '—'}</span> )}
<button className="btn small" onClick={() => beginEditCategories(t)}>Change</button> </td>
{/* Actions cell */}
<td data-label="Actions">
{editingTxId === t.id ? (
<div className="actions" style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button className="btn small" onClick={saveEditTransaction}>Save</button>
<button className="btn small" onClick={cancelEditTransaction}>Cancel</button>
<button className="btn small" onClick={() => handleDeleteTransaction(t.id)}>Delete</button>
</div>
) : (
<div className="actions" style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button className="btn small" onClick={() => beginEditTransaction(t)}>Edit</button>
<button className="btn small" onClick={() => handleDeleteTransaction(t.id)}>Delete</button>
</div> </div>
)} )}
</td> </td>
@@ -317,11 +565,27 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
<AccountPage onDeleted={onLogout} /> <AccountPage onDeleted={onLogout} />
)} )}
{current === 'manual' && (
<ManualManagement
categories={categories}
onTransactionAdded={(t) => setTransactions(prev => [t, ...prev])}
onCategoryCreated={(c) => setCategories(prev => [...prev, c])}
/>
)}
{current === 'appearance' && ( {current === 'appearance' && (
<AppearancePage /> <AppearancePage />
)} )}
</main> </main>
</div> </div>
<MockBankModal
isOpen={isMockModalOpen}
isGenerating={isGenerating}
categories={categories}
onClose={() => setMockModalOpen(false)}
onGenerate={handleGenerateMockTransactions}
/>
{sidebarOpen && <div className="backdrop" onClick={() => setSidebarOpen(false)} />}
</div> </div>
); );
} }

View File

@@ -80,7 +80,7 @@ export default function LoginRegisterPage({ onLoggedIn }: { onLoggedIn: () => vo
<input className="input" type="password" required value={password} onChange={(e) => setPassword(e.target.value)} /> <input className="input" type="password" required value={password} onChange={(e) => setPassword(e.target.value)} />
</div> </div>
{mode === 'register' && ( {mode === 'register' && (
<div className="form-row"> <div className="space-y">
<div> <div>
<label className="muted">First name (optional)</label> <label className="muted">First name (optional)</label>
<input className="input" type="text" value={firstName} onChange={(e) => setFirstName(e.target.value)} /> <input className="input" type="text" value={firstName} onChange={(e) => setFirstName(e.target.value)} />

View File

@@ -0,0 +1,79 @@
import { useState } from 'react';
import { type Category, type Transaction, createTransaction, createCategory } from '../api';
export default function ManualManagement({
categories,
onTransactionAdded,
onCategoryCreated,
}: {
categories: Category[];
onTransactionAdded: (t: Transaction) => void;
onCategoryCreated: (c: Category) => void;
}) {
// New transaction form state
const [amount, setAmount] = useState<string>('');
const [description, setDescription] = useState('');
const [selectedCategoryId, setSelectedCategoryId] = useState<number | ''>('');
const [txDate, setTxDate] = useState<string>('');
// Category creation form
const [newCatName, setNewCatName] = useState('');
const [newCatDesc, setNewCatDesc] = useState('');
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!amount) return;
const payload = {
amount: Number(amount),
description: description || undefined,
category_ids: selectedCategoryId !== '' ? [Number(selectedCategoryId)] : undefined,
date: txDate || undefined,
};
try {
const created = await createTransaction(payload);
onTransactionAdded(created);
setAmount(''); setDescription(''); setSelectedCategoryId(''); setTxDate('');
} catch (err: any) {
alert(err?.message || 'Failed to create transaction');
}
}
async function handleCreateCategory(e: React.FormEvent) {
e.preventDefault();
if (!newCatName.trim()) return;
try {
const cat = await createCategory({ name: newCatName.trim(), description: newCatDesc || undefined });
onCategoryCreated(cat);
setNewCatName(''); setNewCatDesc('');
} catch (err: any) {
alert(err?.message || 'Failed to create category');
}
}
return (
<>
<section className="card">
<h3>Add Transaction</h3>
<form onSubmit={handleCreate} className="form-row">
<input className="input" type="number" step="0.01" placeholder="Amount" value={amount} onChange={(e) => setAmount(e.target.value)} required />
<input className="input" type="date" placeholder="Date (optional)" value={txDate} onChange={(e) => setTxDate(e.target.value)} />
<input className="input" type="text" placeholder="Description (optional)" value={description} onChange={(e) => setDescription(e.target.value)} />
<select className="input" value={selectedCategoryId} onChange={(e) => setSelectedCategoryId(e.target.value ? Number(e.target.value) : '')}>
<option value="">No category</option>
{categories.map(c => (<option key={c.id} value={c.id}>{c.name}</option>))}
</select>
<button className="btn primary" type="submit">Add</button>
</form>
</section>
<section className="card">
<h3>Categories</h3>
<form className="form-row" onSubmit={handleCreateCategory}>
<input className="input" type="text" placeholder="New category name" value={newCatName} onChange={(e) => setNewCatName(e.target.value)} />
<input className="input" type="text" placeholder="Description (optional)" value={newCatDesc} onChange={(e) => setNewCatDesc(e.target.value)} />
<button className="btn primary" type="submit">Create category</button>
</form>
</section>
</>
);
}

View File

@@ -0,0 +1,100 @@
// src/MockBankModal.tsx
import { useState } from 'react';
import { type Category } from '../api';
// Define the shape of the generation options
export interface MockGenerationOptions {
count: number;
minAmount: number;
maxAmount: number;
startDate: string;
endDate: string;
categoryIds: number[];
}
interface MockBankModalProps {
isOpen: boolean;
isGenerating: boolean;
categories: Category[]; // Pass in available categories
onClose: () => void;
onGenerate: (options: MockGenerationOptions) => void;
}
export default function MockBankModal({ isOpen, isGenerating, categories, onClose, onGenerate }: MockBankModalProps) {
// State for all the new form fields
const [count, setCount] = useState('10');
const [minAmount, setMinAmount] = useState('-200');
const [maxAmount, setMaxAmount] = useState('200');
const [startDate, setStartDate] = useState(() => new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]); // Default to one year ago
const [endDate, setEndDate] = useState(() => new Date().toISOString().split('T')[0]); // Default to today
const [selectedCategoryIds, setSelectedCategoryIds] = useState<string[]>([]);
if (!isOpen) return null;
function handleGenerateClick() {
const parsedCount = parseInt(count, 10);
const parsedMinAmount = parseFloat(minAmount);
const parsedMaxAmount = parseFloat(maxAmount);
const parsedStartDate = new Date(startDate);
const parsedEndDate = new Date(endDate);
// Validation
if (
isNaN(parsedCount) || parsedCount <= 0 ||
isNaN(parsedMinAmount) || isNaN(parsedMaxAmount) ||
parsedMaxAmount < parsedMinAmount ||
isNaN(parsedStartDate.getTime()) || isNaN(parsedEndDate.getTime()) ||
parsedEndDate < parsedStartDate
) {
alert(
"Please ensure:\n" +
"- Count is a positive number\n" +
"- Min and Max Amount are valid numbers, and Max >= Min\n" +
"- Start and End Date are valid, and End Date >= Start Date"
);
return;
}
const options: MockGenerationOptions = {
count: parsedCount,
minAmount: parsedMinAmount,
maxAmount: parsedMaxAmount,
startDate,
endDate,
categoryIds: selectedCategoryIds.map(Number),
};
onGenerate(options);
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<h3>Generate Mock Transactions</h3>
<p className="muted">
Customize the random transactions you'd like to import.
</p>
<div className="space-y">
<input className="input" type="number" value={count} onChange={(e) => setCount(e.target.value)} placeholder="Number of transactions" />
<div className="form-row" style={{ gridTemplateColumns: '1fr 1fr' }}>
<input className="input" type="number" value={minAmount} onChange={(e) => setMinAmount(e.target.value)} placeholder="Min amount" />
<input className="input" type="number" value={maxAmount} onChange={(e) => setMaxAmount(e.target.value)} placeholder="Max amount" />
</div>
<div className="form-row" style={{ gridTemplateColumns: '1fr 1fr' }}>
<input className="input" type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} placeholder="Earliest date" />
<input className="input" type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} placeholder="Latest date" />
</div>
<select multiple className="input" style={{ height: '120px' }} value={selectedCategoryIds} onChange={(e) => setSelectedCategoryIds(Array.from(e.target.selectedOptions, option => option.value))}>
{categories.map(c => (<option key={c.id} value={c.id}>{c.name}</option>))}
</select>
</div>
<div className="actions" style={{ justifyContent: 'flex-end', marginTop: '16px' }}>
<button className="btn" onClick={onClose} disabled={isGenerating}>Cancel</button>
<button className="btn primary" onClick={handleGenerateClick} disabled={isGenerating}>
{isGenerating ? 'Generating...' : `Generate Transactions`}
</button>
</div>
</div>
</div>
);
}

View File

@@ -31,14 +31,14 @@ body[data-theme="dark"] {
} }
/* Layout */ /* Layout */
.app-layout { display: grid; grid-template-columns: 260px 1fr; height: 100vh; } .app-layout { display: grid; grid-template-columns: 260px minmax(0,1fr); height: 100vh; }
.sidebar { background: #15172a; color: #e5e7eb; display: flex; flex-direction: column; padding: 20px 12px; } .sidebar { background: #15172a; color: #e5e7eb; display: flex; flex-direction: column; padding: 20px 12px; }
.sidebar .logo { color: #fff; font-weight: 700; font-size: 18px; padding: 12px 14px; display: flex; align-items: center; gap: 10px; } .sidebar .logo { color: #fff; font-weight: 700; font-size: 18px; padding: 12px 14px; display: flex; align-items: center; gap: 10px; }
.nav { margin-top: 12px; display: grid; gap: 4px; } .nav { margin-top: 12px; display: grid; gap: 4px; }
.nav a, .nav button { color: #cbd5e1; text-align: left; background: transparent; border: 0; padding: 10px 12px; border-radius: 8px; cursor: pointer; } .nav a, .nav button { color: #cbd5e1; text-align: left; background: transparent; border: 0; padding: 10px 12px; border-radius: 8px; cursor: pointer; }
.nav a.active, .nav a:hover, .nav button:hover { background: rgba(255,255,255,0.08); color: #fff; } .nav a.active, .nav a:hover, .nav button:hover { background: rgba(255,255,255,0.08); color: #fff; }
.content { display: flex; flex-direction: column; overflow-y: auto; } .content { display: flex; flex-direction: column; overflow-y: auto; min-width: 0; width: 100%; }
.topbar { height: 64px; display: flex; flex-shrink: 0; align-items: center; justify-content: space-between; padding: 0 24px; background: var(--panel); border-bottom: 1px solid var(--border); } .topbar { height: 64px; display: flex; flex-shrink: 0; align-items: center; justify-content: space-between; padding: 0 24px; background: var(--panel); border-bottom: 1px solid var(--border); }
.topbar .user { color: var(--muted); } .topbar .user { color: var(--muted); }
.page { padding: 24px; } .page { padding: 24px; }
@@ -48,26 +48,49 @@ body[data-theme="dark"] {
.card h3 { margin: 0 0 12px; } .card h3 { margin: 0 0 12px; }
/* Forms */ /* Forms */
.input, select, textarea { /* Common field styles (no custom arrow here) */
.input, textarea {
width: 100%; width: 100%;
padding: 10px 12px; padding: 10px 12px;
border-radius: 10px; border-radius: 10px;
border: 1px solid var(--border); border: 1px solid var(--border);
background-color: var(--panel); background-color: var(--panel);
color: var(--muted); color: var(--muted);
}
/* Add these properties specifically for the select element */ /* Select-only: show custom dropdown arrow */
select.input {
-webkit-appearance: none; -webkit-appearance: none;
-moz-appearance: none; -moz-appearance: none;
appearance: none; appearance: none;
padding-right: 32px; /* Add space for the custom arrow */ padding-right: 32px; /* room for the arrow */
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
background-position: right 0.5rem center; background-position: right 0.5rem center;
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: 1.5em 1.5em; background-size: 1.5em 1.5em;
cursor: pointer; cursor: pointer;
} }
.pie-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
@media (max-width: 900px) {
.pie-grid {
grid-template-columns: 1fr;
}
}
/* Make charts scale nicely within the cards */
.pie-card canvas, .pie-card svg {
max-width: 100%;
height: auto;
display: block;
}
.input:focus, select:focus, textarea:focus { .input:focus, select:focus, textarea:focus {
outline: 2px solid var(--primary); outline: 2px solid var(--primary);
outline-offset: 2px; outline-offset: 2px;
@@ -122,3 +145,146 @@ body.auth-page #root {
/* Utility */ /* Utility */
.muted { color: var(--muted); } .muted { color: var(--muted); }
.space-y > * + * { margin-top: 12px; } .space-y > * + * { margin-top: 12px; }
/* Modal mock bank */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: var(--panel);
padding: 24px;
border-radius: var(--radius);
box-shadow: var(--shadow);
width: 100%;
max-width: 400px;
}
.connection-row {
display: flex;
justify-content: space-between;
align-items: center;
}
/* Responsive enhancements */
/* Off-canvas sidebar + hamburger for mobile */
@media (max-width: 900px) {
.app-layout {
grid-template-columns: 1fr;
min-height: 100dvh;
position: relative;
}
.sidebar {
position: fixed;
inset: 0 auto 0 0;
width: 80vw;
max-width: 320px;
transform: translateX(-100%);
transition: transform 200ms ease;
z-index: 1000;
overflow-y: auto;
}
.app-layout.sidebar-open .sidebar {
transform: translateX(0);
}
.hamburger {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
margin-right: 8px;
}
.topbar { position: sticky; top: 0; z-index: 500; }
}
@media (min-width: 901px) {
.hamburger { display: none; }
}
/* Backdrop when sidebar is open */
.backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.45);
z-index: 900;
}
/* Responsive table: convert to card list on small screens */
.table.responsive { width: 100%; }
@media (max-width: 700px) {
.table.responsive thead { display: none; }
.table.responsive tbody tr {
display: block;
border: 1px solid var(--border, #2a2f45);
border-radius: 8px;
margin-bottom: 12px;
overflow: hidden;
background: var(--panel);
}
.table.responsive tbody td {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
text-align: left !important; /* override any right align */
}
.table.responsive tbody td:last-child { border-bottom: 0; }
.table.responsive tbody td::before {
content: attr(data-label);
font-weight: 600;
color: var(--muted);
}
.table.responsive .actions { width: 100%; justify-content: flex-end; }
.table.responsive .amount { font-weight: 600; }
}
/* Filters and controls wrapping */
@media (max-width: 900px) {
.form-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 700px) {
.form-row { grid-template-columns: 1fr; }
}
.table-controls { gap: 12px; }
@media (max-width: 700px) {
.table-controls { flex-direction: column; align-items: stretch; }
.table-controls .actions { width: 100%; }
.table-controls .actions .btn { flex: 1 0 auto; }
}
/* Touch-friendly sizes */
.btn, .input, select.input { min-height: 40px; }
.btn.small { min-height: 36px; }
/* Connection rows on mobile */
@media (max-width: 700px) {
.connection-row { flex-direction: column; align-items: stretch; gap: 8px; }
.connection-row .btn { width: 100%; }
}
/* Charts should scale to container */
.card canvas, .card svg { max-width: 100%; height: auto; display: block; }
/* Horizontal scroll container for wide charts */
.chart-scroll {
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch; /* momentum scroll on iOS */
}
.chart-inner { min-width: 900px; }

View File

@@ -3,5 +3,5 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
}) })

View File

@@ -8,7 +8,7 @@ Just copy the template below for each weekly meeting and fill in the details.
## Administrative Info ## Administrative Info
- Date: 2025-10-08 - Date: 2025-10-16
- Attendees: Dejan Ribarovski, Lukas Trkan - Attendees: Dejan Ribarovski, Lukas Trkan
- Notetaker: Dejan Ribarovski - Notetaker: Dejan Ribarovski

View File

@@ -0,0 +1,54 @@
# 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-23
- Attendees: Dejan
- Notetaker: Dejan
## Progress Update (Before Meeting)
Last 3 minutes of the meeting, summarize action items.
- [x] OAuth (BankID)
- [x] CI/CD fix
- [X] Database local (multiple bank accounts)
- [X] Add tests and set up github pipeline
- [X] Frontend imporvment - user experience
- [ ] make the report more clear - partly
Summary of what has been accomplished since the last meeting in the following categories.
### Coding
Improved Frontend, added Mock Bank, fixed deployment, fixed OAuth(BankID) on production, added basic tests
### Documentation
Not much - just updated the work done
## Questions and Topics for Discussion (Before Meeting)
This was not prepared, I planned to do it right before meeting, but Jaychander needed to go somewhere earlier.
1. Question 1
2. Question 2
3. Question 3
## 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.
- [ ] Dont store data in database (security) - Load it on login (from CSAS API and local database), load automatically with email
- [ ] Go through the checklist
- [ ] Look for possible APIs (like stocks or financial details whatever)
- [ ] Report
---

View File

@@ -0,0 +1,51 @@
# 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.
- [ ] Dont store data in database (security) - Load it on login (from CSAS API and local database), load automatically with email
- [X] Go through the checklist
- [X] Look for possible APIs (like stocks or financial details whatever)
- [ ] Report - partly
Summary of what has been accomplished since the last meeting in the following categories.
### Coding
Implemented CSAS API transactions fetch, Added tests with testing database on github actions, redone UI,
added currency exchange rate with CNB API
### Documentation
Not much - just updated the work done
## Questions and Topics for Discussion (Before Meeting)
1. Security regarding storing transactions - possibility of encryption
2. Realisticaly what needs to be done for us to be done
3. Question 3
## 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.
- [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

@@ -14,7 +14,7 @@
- 289229, Lukáš Trkan, lukastrkan - 289229, Lukáš Trkan, lukastrkan
- 289258, Dejan Ribarovski, derib2613, ribardej - 289258, Dejan Ribarovski, derib2613, ribardej
**Brief Description**: (něco spíš jako abstract, introuction, story behind) **Brief Description**:
Our application is a finance tracker, so a person can easily track his cash flow 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 through multiple bank accounts. Person can label transactions with custom categories
and later filter by them. and later filter by them.
@@ -34,9 +34,16 @@ flowchart LR
client[Client/Frontend] <--> svc[Backend API] client[Client/Frontend] <--> svc[Backend API]
svc --> proc_queue svc --> proc_queue
svc <--> db[(Database)] svc <--> db[(Database)]
svc <--> cache[(Cache)]
``` ```
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
- When the client opts for fetching new transactions via the Bank API, the backend delegates the task
to a background worker service via the Message queue.
- After successful load, these transactions are stored to the database and displayed to the client
- There is also a Task planner, that executes periodic tasks, like fetching new transactions automatically from the Bank API
### Components ### 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. - 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.
@@ -52,46 +59,45 @@ flowchart LR
- Backend: Python, FastAPI, FastAPI Users, SQLAlchemy, Pydantic, Alembic, Celery - Backend: Python, FastAPI, FastAPI Users, SQLAlchemy, Pydantic, Alembic, Celery
- Frontend: React, TypeScript, Vite - Frontend: React, TypeScript, Vite
- Database: PostgreSQL - Database: MariaDB (Maxscale)
- Messaging: RabbitMQ - Background jobs: RabbitMQ, Celery
- Cache: Redis
- Containerization/Orchestration: Docker, Docker Compose (dev), Kubernetes, Helm - 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 ## Prerequisites
### System Requirements ### System Requirements
- Operating System: Linux, macOS, or Windows - Operating System (dev): Linux, macOS, or Windows with Docker support
- Operating System (prod): Linux with kubernetes
- Minimum RAM: 4 GB (8 GB recommended for running backend, frontend, and database together) - Minimum RAM: 4 GB (8 GB recommended for running backend, frontend, and database together)
- Storage: 2 GB free (Docker images may require additional space) - Storage: 4 GB free (Docker images may require additional space)
### Required Software ### Required Software
- Docker Desktop or Docker Engine 24+ - Docker Desktop or Docker Engine
- Docker Compose v2+ - Docker Compose
- Node.js 20+ and npm 10+ (for local frontend dev/build) - Node.js and npm
- Python 3.12+ (for local backend dev outside Docker) - Python 3.12+
- PostgreSQL 15+ (optional if running DB outside Docker) - MariaDB 11
- Helm 3.12+ and kubectl 1.29+ (for Kubernetes deployment) - Helm 3.12+ and kubectl 1.29+
- OpenTofu 1.7+ (for infrastructure provisioning) - OpenTofu
### Environment Variables (common) ### Environment Variables (common)
# TODO: UPDATE
- Backend: SECRET, FRONTEND_URL, BACKEND_URL, DATABASE_URL, RABBITMQ_URL, REDIS_URL - Backend: SECRET, FRONTEND_URL, BACKEND_URL, DATABASE_URL, RABBITMQ_URL, REDIS_URL
- OAuth vars (Backend): MOJEID_CLIENT_ID/SECRET, BANKID_CLIENT_ID/SECRET (optional) - OAuth vars (Backend): MOJEID_CLIENT_ID/SECRET, BANKID_CLIENT_ID/SECRET (optional)
- Frontend: VITE_BACKEND_URL - Frontend: VITE_BACKEND_URL
### Dependencies (key libraries) ### Dependencies (key libraries)
I am not sure what is meant by "key libraries" Backend: FastAPI, fastapi-users, SQLAlchemy, pydantic v2, Alembic, Celery, uvicorn
Frontend: React, TypeScript, Vite
Backend: FastAPI, fastapi-users, SQLAlchemy, pydantic v2, Alembic, Celery ## Local development
Frontend: React, TypeScript, Vite
Services: PostgreSQL, RabbitMQ, Redis
## Build Instructions You can run the project with Docker Compose and Python virtual environment for testing and dev purposes
You can run the project with Docker Compose (recommended for local development) or run services manually.
### 1) Clone the Repository ### 1) Clone the Repository
@@ -103,9 +109,8 @@ cd 7project
### 2) Install dependencies ### 2) Install dependencies
Backend Backend
```bash ```bash
# In 7project/backend python3 -m venv .venv
python3.12 -m venv .venv source .venv/bin/activate
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt pip install -r requirements.txt
``` ```
Frontend Frontend
@@ -120,24 +125,25 @@ Backend
```bash ```bash
# From the 7project/ directory # From the 7project/ directory
docker compose up --build docker compose up --build
# This starts: PostgreSQL, RabbitMQ/Redis (if defined) # This starts: MariaDB, RabbitMQ
# Set environment variables (or create .env file) # Set environment variables (or create .env file)
# TODO: fix
export SECRET=CHANGE_ME_SECRET export SECRET=CHANGE_ME_SECRET
export BACKEND_URL=http://127.0.0.1:8000 export FRONTEND_DOMAIN_SCHEME=http://localhost:5173
export FRONTEND_URL=http://localhost:5173 export BANKID_CLIENT_ID=CHANGE_ME
export DATABASE_URL=postgresql+asyncpg://user:password@127.0.0.1:5432/app export BANKID_CLIENT_SECRET=CHANGE_ME
export RABBITMQ_URL=amqp://guest:guest@127.0.0.1:5672/ export CSAS_CLIENT_ID=CHANGE_ME
export REDIS_URL=redis://127.0.0.1:6379/0 export CSAS_CLIENT_SECRET=CHANGE_ME
export MOJEID_CLIENT_ID=CHANGE_ME
export MOJEID_CLIENT_SECRET=CHANGE_ME
# Apply DB migrations (Alembic) # Apply DB migrations (Alembic)
# From 7project/backend # From 7project
alembic upgrade head bash upgrade_database.sh
# Run API # Run API
uvicorn app.app:fastApi --reload --host 0.0.0.0 --port 8000 uvicorn app.app:fastApi --reload --host 0.0.0.0 --port 8000
# Run Celery worker (optional, for emails/background tasks)
celery -A app.celery_app.celery_app worker -l info celery -A app.celery_app.celery_app worker -l info
``` ```
@@ -152,26 +158,61 @@ npm run dev
- Backend default: http://127.0.0.1:8000 (OpenAPI at /docs) - Backend default: http://127.0.0.1:8000 (OpenAPI at /docs)
- Frontend default: http://localhost:5173 - Frontend default: http://localhost:5173
If needed, adjust compose services/ports in compose.yml. ## Build Instructions
### Backend
```bash
# run in project7/backend
docker buildx build --platform linux/amd64,linux/arm64 -t your_container_registry/your_name --push .
```
### Frontend
```bash
# run in project7/frontend
npm ci
npm run build
```
## Deployment Instructions ## Deployment Instructions
### Setup Cluster
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.
### Local (Docker Compose) 1) Create 4 VMs with TalosOS
2) Install talosctl for your OS: https://docs.siderolabs.com/talos/v1.10/getting-started/talosctl
3) Generate Talos config
```bash
# TODO: add commands
```
4) Edit the generated worker.yaml
- add google container registry mirror
- add modules from config generator
- add extramounts for persistent storage
- add kernel modules
Described in the previous section (Manual Local Run) 5) Apply the config to the VMs
```bash
#TODO: add config apply commands
```
### Kubernetes (via OpenTofu + Helm) 6) Verify the cluster is up
```bash
```
1) Provision platform services (RabbitMQ/Redis/ingress/tunnel/etc.) with OpenTofu 7) Export kubeconfig
```bash
# TODO: add export command
```
### Install
1) Install base services to cluster
```bash ```bash
cd tofu cd tofu
# copy and edit variables # copy and edit variables
cp terraform.tfvars.example terraform.tfvars cp terraform.tfvars.example terraform.tfvars
# authenticate to your cluster/cloud as needed, then: # authenticate to your cluster/cloud as needed, then:
tofu init tofu init
tofu plan tofu apply -exclude modules.cloudflare
tofu apply tofu apply
``` ```
2) Deploy the app using Helm 2) Deploy the app using Helm
@@ -215,28 +256,28 @@ open http://localhost:5173
``` ```
## Testing Instructions ## Testing Instructions
The tests are located in 7project/backend/tests directory
If you want to test locally, you have to have the DB running locally as well (start the docker compose in /backend).
```bash
cd backend
```
### Unit Tests ### Unit Tests
There are only 3 basic unit tests, since our services logic is very simple
```bash ```bash
# Commands to run unit tests pytest tests/test_unit_user_service.py
# For example:
# go test ./...
# npm test
``` ```
### Integration Tests ### Integration Tests
There are 11 basic unit tests, testing the individual backend API logic
```bash ```bash
# Commands to run integration tests pytest tests/test_integration_app.py
# Any setup required for integration tests
``` ```
### End-to-End Tests ### End-to-End Tests
There are 7 e2e tests testing more complex app logic
```bash ```bash
# Commands to run e2e tests pytest tests/test_e2e.py
# How to set up test environment
``` ```
## Usage Examples ## Usage Examples
@@ -313,24 +354,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. > Be honest and detailed in your assessments.
> This information is used for individual grading. > This information is used for individual grading.
> Link to the specific commit on GitHub for each contribution. > Link to the specific commit on GitHub for each contribution.
| Task/Component | Assigned To | Status | Time Spent | Difficulty | 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] | | [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] | | [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 | 10 hours | Medium | [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] | | [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] | | [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 | [X hours] | Easy | [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] | | [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 | ❌ Not Started | [X hours] | Medium | [Any notes] | | [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 | ❌ Not Started | [X hours] | Easy | [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] | | [Presentation Video](https://github.com/dat515-2025/group-name) | Both | ❌ Not Started | [X hours] | Medium | [Any notes] |
**Legend**: ✅ Complete | 🔄 In Progress | ⏳ Pending | ❌ Not Started **Legend**: ✅ Complete | 🔄 In Progress | ⏳ Pending | ❌ Not Started
@@ -351,13 +392,18 @@ curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8000/authenticated-route
### Dejan ### Dejan
| Date | Activity | Hours | Description | | Date | Activity | Hours | Description |
|-------------|----------------------|--------|--------------------------------| |-----------------|----------------------|--------|---------------------------------------------------------------|
| 25.9. | Design | 1.5 | 6design | | 25.9. | Design | 2 | 6design |
| 9-11.10. | Backend APIs | 10 | Implemented Backend APIs | | 9.10 to 11.10. | Backend APIs | 12 | Implemented Backend APIs |
| 13-15.10. | Frontend Development | 6.5 | Created user interface mockups | | 13.10 to 15.10. | Frontend Development | 8 | Created user interface mockups |
| Continually | Documantation | 3 | Documenting the dev process | | Continually | Documentation | 6 | Documenting the dev process |
| **Total** | | **21** | | | 21.10 to 23.10 | Tests, frontend | 10 | Test basics, balance charts, and frontend improvement |
| 28.10 to 30.10 | CI | 6 | Integrated tests with test database setup on github workflows |
| 28.10 to 30.10 | Frontend | 7 | UI improvements and exchange rate API integration |
| 4.11 to 6.11 | Tests | 6 | Test fixes improvement, more integration and e2e |
| 4.11 to 6.11 | Frontend | 6 | Fixes, Improved UI, added support for mobile devices |
| **Total** | | **63** | |
### Group Total: [XXX.X] hours ### Group Total: [XXX.X] hours

View File

@@ -64,3 +64,21 @@ resource "kubectl_manifest" "argocd-tunnel-bind" {
base_domain = var.cloudflare_domain base_domain = var.cloudflare_domain
}) })
} }
resource "helm_release" "loki_stack" {
name = "loki-stack"
repository = "https://grafana.github.io/helm-charts"
chart = "loki-stack"
namespace = kubernetes_namespace.monitoring.metadata[0].name
version = "2.9.12"
set = [{
name = "grafana.enabled"
value = "false"
}]
depends_on = [
helm_release.kube_prometheus_stack
]
}

View File

@@ -1,72 +0,0 @@
aio-pika==9.5.6
aiormq==6.8.1
aiosqlite==0.21.0
alembic==1.16.5
amqp==5.3.1
annotated-types==0.7.0
anyio==4.11.0
argon2-cffi==23.1.0
argon2-cffi-bindings==25.1.0
asyncmy==0.2.9
bcrypt==4.3.0
billiard==4.2.2
celery==5.5.3
certifi==2025.10.5
cffi==2.0.0
click==8.1.8
click-didyoumean==0.3.1
click-plugins==1.1.1.2
click-repl==0.3.0
cryptography==46.0.1
dnspython==2.7.0
email_validator==2.2.0
exceptiongroup==1.3.0
fastapi==0.117.1
fastapi-users==14.0.1
fastapi-users-db-sqlalchemy==7.0.0
greenlet==3.2.4
h11==0.16.0
httpcore==1.0.9
httptools==0.6.4
httpx==0.28.1
httpx-oauth==0.16.1
idna==3.10
iniconfig==2.3.0
kombu==5.5.4
makefun==1.16.0
Mako==1.3.10
MarkupSafe==3.0.2
multidict==6.6.4
packaging==25.0
pamqp==3.3.0
pluggy==1.6.0
prompt_toolkit==3.0.52
propcache==0.3.2
pwdlib==0.2.1
pycparser==2.23
pydantic==2.11.9
pydantic_core==2.33.2
Pygments==2.19.2
PyJWT==2.10.1
PyMySQL==1.1.2
pytest==8.4.2
pytest-asyncio==1.2.0
python-dateutil==2.9.0.post0
python-dotenv==1.1.1
python-multipart==0.0.20
PyYAML==6.0.2
six==1.17.0
sniffio==1.3.1
SQLAlchemy==2.0.43
starlette==0.48.0
tomli==2.2.1
typing-inspection==0.4.1
typing_extensions==4.15.0
tzdata==2025.2
uvicorn==0.37.0
uvloop==0.21.0
vine==5.1.0
watchfiles==1.1.0
wcwidth==0.2.14
websockets==15.0.1
yarl==1.20.1