Compare commits

33 Commits

Author SHA1 Message Date
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
d6a913a896 feat(worker): add transaction saving to db 2025-10-29 18:11:53 +01:00
ribardej
4aa299d77d feat(docs): went through the checklist.md 2025-10-29 13:18:14 +01:00
27 changed files with 1081 additions and 261 deletions

View File

@@ -101,7 +101,8 @@ jobs:
--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 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

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

@@ -1,9 +1,12 @@
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 import bank_scraper from app.services import bank_scraper
@@ -15,11 +18,11 @@ 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.core.security import extract_bearer_token, is_token_revoked, decode_and_verify_jwt
from app.services.user_service import SECRET from app.services.user_service import SECRET
from fastapi import FastAPI from fastapi import FastAPI
import sentry_sdk import sentry_sdk
from fastapi_users.db import SQLAlchemyUserDatabase from fastapi_users.db import SQLAlchemyUserDatabase
@@ -31,7 +34,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(
@@ -46,11 +48,38 @@ fastApi.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
prometheus = Instrumentator().instrument(fastApi)
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") @fastApi.middleware("http")
async def auth_guard(request: Request, call_next): async def auth_guard(request: Request, call_next):
# Enforce revoked/expired JWTs are rejected globally # Enforce revoked/expired JWTs are rejected globally
@@ -85,9 +114,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"),
@@ -114,6 +144,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():
@@ -132,8 +163,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

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

@@ -21,4 +21,4 @@ class Transaction(Base):
# 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

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

@@ -4,7 +4,7 @@ from httpx import AsyncClient, ASGITransport
from fastapi import status from fastapi import status
def test_e2e_minimal_auth_flow(client): def test_e2e(client):
# 1) Service is alive # 1) Service is alive
alive = client.get("/") alive = client.get("/")
assert alive.status_code == status.HTTP_200_OK assert alive.status_code == status.HTTP_200_OK
@@ -95,4 +95,85 @@ async def test_e2e_transaction_workflow(fastapi_app, test_user):
# NEW STEP: Clean up the created category # NEW STEP: Clean up the created category
delete_category_resp = await ac.delete(f"/categories/{category_id}", headers=headers) 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) 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:
email = "newuser@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"]
me = await ac.get("/users/me", headers={"Authorization": f"Bearer {token}"})
assert me.status_code == status.HTTP_200_OK
assert me.json()["email"] == email
@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:
# user1
u1 = {"email": "u1@example.com", "password": "Aaaaaa1!"}
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"]
# user1 creates a category
c = (await ac.post("/categories/create", json={"name": "Private"}, headers={"Authorization": f"Bearer {t1}"})).json()
# user2
u2 = {"email": "u2@example.com", "password": "Aaaaaa1!"}
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"]
# user2 cannot read/delete user1's category
g = await ac.get(f"/categories/{c['id']}", headers={"Authorization": f"Bearer {t2}"})
assert g.status_code == status.HTTP_404_NOT_FOUND
d = await ac.delete(f"/categories/{c['id']}", headers={"Authorization": f"Bearer {t2}"})
assert d.status_code == status.HTTP_404_NOT_FOUND

View File

@@ -63,4 +63,108 @@ async def test_create_transaction_missing_amount_fails(fastapi_app, test_user):
resp = await ac.post("/transactions/create", json=invalid_payload, headers=headers) resp = await ac.post("/transactions/create", json=invalid_payload, headers=headers)
# 4. Assert the expected validation error # 4. Assert the expected validation error
assert resp.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY 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

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

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

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

@@ -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,18 +1,156 @@
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 MockBankModal, { type MockGenerationOptions } from './MockBankModal'; import MockBankModal, { type MockGenerationOptions } from './MockBankModal';
import { BACKEND_URL } from '../config'; 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);
@@ -41,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>('');
@@ -63,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);
@@ -167,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>
@@ -237,28 +396,7 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
</div> </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>
@@ -317,39 +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>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.date || ''}</td> {/* Date cell */}
<td className="amount">{formatAmount(t.amount)}</td> <td data-label="Date">
<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>
@@ -368,6 +565,14 @@ 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 />
)} )}
@@ -380,6 +585,7 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
onClose={() => setMockModalOpen(false)} onClose={() => setMockModalOpen(false)}
onGenerate={handleGenerateMockTransactions} 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

@@ -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;
@@ -151,3 +174,117 @@ body.auth-page #root {
justify-content: space-between; justify-content: space-between;
align-items: center; 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

@@ -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.
- [ ] Change the name on frontend from 7project
- [ ] 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

@@ -52,46 +52,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 +102,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,9 +118,10 @@ 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 BACKEND_URL=http://127.0.0.1:8000
export FRONTEND_URL=http://localhost:5173 export FRONTEND_URL=http://localhost:5173
@@ -131,13 +130,12 @@ export RABBITMQ_URL=amqp://guest:guest@127.0.0.1:5672/
export REDIS_URL=redis://127.0.0.1:6379/0 export REDIS_URL=redis://127.0.0.1:6379/0
# 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,18 +150,22 @@ 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
### Local (Docker Compose) 1) Install base services to cluster
Described in the previous section (Manual Local Run)
### Kubernetes (via OpenTofu + Helm)
1) Provision platform services (RabbitMQ/Redis/ingress/tunnel/etc.) with OpenTofu
```bash ```bash
cd tofu cd tofu
# copy and edit variables # copy and edit variables
@@ -323,13 +325,13 @@ curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8000/authenticated-route
|-----------------------------------------------------------------------|-------------| ------------- |----------------|------------| ----------- | |-----------------------------------------------------------------------|-------------| ------------- |----------------|------------| ----------- |
| [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 | 2 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 | 🔄 In Progress | 10 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 | 🔄 In Progress | 7 hours so far | Medium | [Any notes] |
| [Docker Configuration](https://github.com/dat515-2025/Group-8/blob/main/7project/compose.yml) | Lukas | ✅ Complete | [X hours] | Easy | [Any notes] | | [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 | 🔄 In Progress | [X 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 +353,15 @@ 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 | 10 | Implemented Backend APIs |
| 13-15.10. | Frontend Development | 6.5 | Created user interface mockups | | 13.10 to 15.10. | Frontend Development | 7 | Created user interface mockups |
| Continually | Documantation | 3 | Documenting the dev process | | Continually | Documantation | 5 | Documenting the dev process |
| **Total** | | **21** | | | 21.10 to 23.10 | Tests, forntend | 10 | Test basics, balance charts, and frontend improvement |
| 28.10 to 30.10 | Tests, forntend | 7 | Tests improvement with test database setup, UI fix and exchange rate integration |
| **Total** | | **41** | |
### 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
]
}