Compare commits

65 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
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
Dejan Ribarovski
6d7f834808 Merge pull request #35 from dat515-2025/34-improve-frontend-functionality
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
Run Python Tests / build-and-test (push) Waiting to run
34 improve frontend functionality
2025-10-23 10:02:57 +02:00
ribardej
d5611e3e92 fix(frontend): Fixed type error 2025-10-23 09:57:15 +02:00
ribardej
5ecfc62b02 feat(frontend): improved UI 2025-10-23 09:22:10 +02:00
d0cbec5fca update report
Some checks are pending
Run Python Tests / build-and-test (push) Waiting to run
2025-10-22 21:52:14 +02:00
ribardej
82eb34c6e6 feat(frontend): improved Dashboard.tsx, added transaction date 2025-10-22 17:37:11 +02:00
Dejan Ribarovski
cddc1d3a9f Merge pull request #31 from dat515-2025/30-create-tests-and-set-up-a-github-pipeline
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
Run Python Tests / build-and-test (push) Waiting to run
30 create tests and set up a GitHub pipeline
2025-10-22 14:59:54 +02:00
54 changed files with 2602 additions and 421 deletions

View File

@@ -12,25 +12,7 @@ jobs:
test:
name: Run Python Tests
if: github.event.action != 'closed'
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
uses: ./.github/workflows/run-tests.yml
build:
if: github.event.action != 'closed'
@@ -118,7 +100,9 @@ jobs:
--set frontend_domain_scheme="$FRONTEND_DOMAIN_SCHEME" \
--set image.digest="$DIGEST" \
--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
uses: actions/github-script@v7

View File

@@ -23,26 +23,7 @@ concurrency:
jobs:
test:
name: Run Python Tests
if: github.event.action != 'closed'
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
uses: ./.github/workflows/run-tests.yml
build:
name: Build and push image (reusable)
@@ -129,4 +110,5 @@ jobs:
--set-string oauth.mojeid.clientSecret="$MOJEID_CLIENT_SECRET" \
--set-string oauth.csas.clientId="$CSAS_CLIENT_ID" \
--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:
contents: read
# -----------------
# --- Triggers ----
# -----------------
# This section defines when the workflow will run.
on:
# Run on every push to the 'main' branch
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" ]
workflow_call:
# -----------------
# ------ Jobs -----
# -----------------
# A workflow is made up of one or more jobs that can run in parallel or sequentially.
jobs:
# A descriptive name for your job
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
# -----------------
# ----- Steps -----
# -----------------
# A sequence of tasks that will be executed as part of the job.
services:
mariadb:
image: mariadb:11.4
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:
# Step 1: Check out your repository's code
# This action allows the workflow to access your code.
- name: Check out repository code
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
uses: actions/setup-python@v5
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
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
# Step 4: Run your tests!
# Executes the pytest command to run your test suite.
pip install -r ./7project/backend/requirements.txt
- name: Run Alembic migrations
run: |
alembic upgrade head
working-directory: ./7project/backend
- name: Run tests with pytest
run: pytest
working-directory: ./7project/backend

View File

@@ -25,7 +25,8 @@ if not DATABASE_URL:
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 {}
def run_migrations_offline() -> None:

View File

@@ -0,0 +1,32 @@
"""add date to transaction
Revision ID: 1f2a3c4d5e6f
Revises: eabec90a94fe
Create Date: 2025-10-22 16:18:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import func
# revision identifiers, used by Alembic.
revision: str = '1f2a3c4d5e6f'
down_revision: Union[str, Sequence[str], None] = 'eabec90a94fe'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema by adding date column with server default current_date."""
op.add_column(
'transaction',
sa.Column('date', sa.Date(), nullable=False, server_default=sa.text('CURRENT_DATE'))
)
def downgrade() -> None:
"""Downgrade schema by removing date column."""
op.drop_column('transaction', 'date')

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)
# 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(
fastapi_users.get_auth_router(auth_backend), prefix="/auth/jwt", tags=["auth"]
)

View File

@@ -5,7 +5,7 @@ from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.categories import Category
from app.schemas.category import CategoryCreate, CategoryRead
from app.schemas.category import CategoryCreate, CategoryRead, CategoryUpdate
from app.services.db import get_async_session
from app.services.user_service import current_active_user
from app.models.user import User
@@ -43,6 +43,37 @@ async def list_categories(
return list(res.scalars())
@router.patch("/{category_id}", response_model=CategoryRead)
async def update_category(
category_id: int,
payload: CategoryUpdate,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
res = await session.execute(
select(Category).where(Category.id == category_id, Category.user_id == user.id)
)
category = res.scalar_one_or_none()
if not category:
raise HTTPException(status_code=404, detail="Category not found")
# If name changed, check uniqueness per user
if payload.name is not None and payload.name != category.name:
dup = await session.execute(
select(Category.id).where(Category.user_id == user.id, Category.name == payload.name)
)
if dup.scalar_one_or_none() is not None:
raise HTTPException(status_code=409, detail="Category with this name already exists")
category.name = payload.name
if payload.description is not None:
category.description = payload.description
await session.commit()
await session.refresh(category)
return category
@router.get("/{category_id}", response_model=CategoryRead)
async def get_category(
category_id: int,

View File

@@ -1,7 +1,8 @@
from typing import List, Optional
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy import select, and_, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.transaction import Transaction
@@ -23,6 +24,7 @@ def _to_read_model(tx: Transaction) -> TransactionRead:
id=tx.id,
amount=tx.amount,
description=tx.description,
date=tx.date,
category_ids=[c.id for c in (tx.categories or [])],
)
@@ -33,7 +35,21 @@ async def create_transaction(
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
tx = Transaction(amount=payload.amount, description=payload.description, user_id=user.id)
# Build transaction; set `date` only if provided to let DB default apply otherwise
tx_kwargs = dict(
amount=payload.amount,
description=payload.description,
user_id=user.id,
)
if payload.date is not None:
parsed_date = payload.date
if isinstance(parsed_date, str):
try:
parsed_date = date.fromisoformat(parsed_date)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format, expected YYYY-MM-DD")
tx_kwargs["date"] = parsed_date
tx = Transaction(**tx_kwargs)
# Attach categories if provided (and owned by user)
if payload.category_ids:
@@ -60,11 +76,18 @@ async def create_transaction(
@router.get("/", response_model=List[TransactionRead])
async def list_transactions(
start_date: Optional[date] = None,
end_date: Optional[date] = None,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
cond = [Transaction.user_id == user.id]
if start_date is not None:
cond.append(Transaction.date >= start_date)
if end_date is not None:
cond.append(Transaction.date <= end_date)
res = await session.execute(
select(Transaction).where(Transaction.user_id == user.id).order_by(Transaction.id)
select(Transaction).where(and_(*cond)).order_by(Transaction.date, Transaction.id)
)
txs = list(res.scalars())
# Eagerly load categories for each transaction
@@ -73,6 +96,36 @@ async def list_transactions(
return [_to_read_model(tx) for tx in txs]
@router.get("/balance_series")
async def get_balance_series(
start_date: Optional[date] = None,
end_date: Optional[date] = None,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
cond = [Transaction.user_id == user.id]
if start_date is not None:
cond.append(Transaction.date >= start_date)
if end_date is not None:
cond.append(Transaction.date <= end_date)
res = await session.execute(
select(Transaction).where(and_(*cond)).order_by(Transaction.date, Transaction.id)
)
txs = list(res.scalars())
# Group by date and accumulate
daily = {}
for tx in txs:
key = tx.date.isoformat() if hasattr(tx.date, 'isoformat') else str(tx.date)
daily[key] = daily.get(key, 0.0) + float(tx.amount)
# Build cumulative series sorted by date
series = []
running = 0.0
for d in sorted(daily.keys()):
running += daily[d]
series.append({"date": d, "balance": running})
return series
@router.get("/{transaction_id}", response_model=TransactionRead)
async def get_transaction(
transaction_id: int,
@@ -111,6 +164,14 @@ async def update_transaction(
tx.amount = payload.amount
if payload.description is not None:
tx.description = payload.description
if payload.date is not None:
new_date = payload.date
if isinstance(new_date, str):
try:
new_date = date.fromisoformat(new_date)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format, expected YYYY-MM-DD")
tx.date = new_date
if payload.category_ids is not None:
# Preload categories to avoid async lazy-load during assignment

View File

@@ -1,9 +1,12 @@
import logging
import os
import sys
from datetime import datetime
from pythonjsonlogger import jsonlogger
from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from prometheus_fastapi_instrumentator import Instrumentator, metrics
from starlette.requests import Request
from app.services import bank_scraper
@@ -15,8 +18,10 @@ from app.api.auth import router as auth_router
from app.api.csas import router as csas_router
from app.api.categories import router as categories_router
from app.api.transactions import router as transactions_router
from app.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
import sentry_sdk
@@ -29,7 +34,6 @@ sentry_sdk.init(
)
fastApi = FastAPI()
app = fastApi
# CORS for frontend dev server
fastApi.add_middleware(
@@ -44,11 +48,55 @@ fastApi.add_middleware(
allow_headers=["*"],
)
prometheus = Instrumentator().instrument(fastApi)
prometheus.expose(
fastApi,
endpoint="/metrics",
include_in_schema=True,
)
fastApi.include_router(auth_router)
fastApi.include_router(categories_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")
async def log_traffic(request: Request, call_next):
start_time = datetime.now()
@@ -66,9 +114,10 @@ async def log_traffic(request: Request, call_next):
"process_time": process_time,
"client_host": client_host
}
logging.info(str(log_params))
logging.getLogger(__name__).info("http_request", extra=log_params)
return response
fastApi.include_router(
fastapi_users.get_oauth_router(
get_oauth_provider("MojeID"),
@@ -95,6 +144,7 @@ fastApi.include_router(
fastApi.include_router(csas_router)
# Liveness/root endpoint
@fastApi.get("/", include_in_schema=False)
async def root():
@@ -105,10 +155,6 @@ async def root():
async def authenticated_route(user: User = Depends(current_active_verified_user)):
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"])
async def debug_scrape_csas_all():
@@ -117,8 +163,9 @@ async def debug_scrape_csas_all():
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)):
logging.info("[Debug] Queueing CSAS scrape for single user via HTTP endpoint (Celery) | user_id=%s", user_id)
task = load_transactions.delay(user_id)
return {"status": "queued", "action": "csas_scrape_single", "user_id": user_id, "task_id": getattr(task, 'id', None)}
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.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 {}
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(
"category_transaction",
Base.metadata,
Column("id_category", Integer, ForeignKey("categories.id")),
Column("id_transaction", Integer, ForeignKey("transaction.id"))
Column("category_id", Integer, ForeignKey("categories.id", ondelete="CASCADE"), primary_key=True),
Column("transaction_id", Integer, ForeignKey("transaction.id", ondelete="CASCADE"), primary_key=True)
)

View File

@@ -1,17 +1,24 @@
import os
from fastapi_users_db_sqlalchemy import GUID
from sqlalchemy import Column, Integer, String, Float, ForeignKey
from sqlalchemy import Column, Integer, String, Float, ForeignKey, Date, func
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.models.categories import association_table
SECRET_KEY = os.environ.get("DB_ENCRYPTION_KEY", "localdev")
class Transaction(Base):
__tablename__ = "transaction"
id = Column(Integer, primary_key=True, autoincrement=True)
amount = Column(Float, nullable=False)
description = Column(String(length=255), nullable=True)
amount = Column(EncryptedType(Float, SECRET_KEY, engine=FernetEngine), nullable=False)
description = Column(EncryptedType(String(length=255), SECRET_KEY, engine=FernetEngine), nullable=True)
date = Column(Date, nullable=False, server_default=func.current_date())
user_id = Column(GUID, ForeignKey("user.id"), nullable=False)
# Relationship
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

@@ -11,6 +11,11 @@ class CategoryCreate(CategoryBase):
pass
class CategoryUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
class CategoryRead(CategoryBase):
id: int
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,10 +1,13 @@
from typing import List, Optional
from typing import List, Optional, Union
from datetime import date
from pydantic import BaseModel, Field, ConfigDict
class TransactionBase(BaseModel):
amount: float = Field(..., gt=-1e18, lt=1e18)
description: Optional[str] = None
# accept either ISO date string or date object
date: Optional[Union[date, str]] = None
class TransactionCreate(TransactionBase):
category_ids: Optional[List[int]] = None
@@ -12,10 +15,12 @@ class TransactionCreate(TransactionBase):
class TransactionUpdate(BaseModel):
amount: Optional[float] = Field(None, gt=-1e18, lt=1e18)
description: Optional[str] = None
# accept either ISO date string or date object
date: Optional[Union[date, str]] = None
category_ids: Optional[List[int]] = None
class TransactionRead(TransactionBase):
id: int
category_ids: List[int] = []
date: Optional[Union[date, str]]
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,17 +1,18 @@
import json
import logging
from os.path import dirname, join
from time import strptime
from uuid import UUID
import httpx
from sqlalchemy import select
from app.core.db import async_session_maker
from app.models.transaction import Transaction
from app.models.user import User
logger = logging.getLogger(__name__)
# Reuse CSAS mTLS certs used by OAuth profile calls
OAUTH_DIR = join(dirname(__file__), "..", "oauth")
CERTS = (
join(OAUTH_DIR, "public_key.pem"),
@@ -20,10 +21,6 @@ CERTS = (
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:
uid = UUID(str(user_id))
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 entry point to load Česká spořitelna transactions for all users.
"""
async with async_session_maker() as session:
result = await session.execute(select(User))
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 with async_session_maker() as session:
async with (async_session_maker() as session):
result = await session.execute(select(User).where(User.id == user_id))
user: User = result.unique().scalar_one_or_none()
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:
continue
# Placeholder: just print the account transactions
transactions = response.json()["transactions"]
pass
for transaction in transactions:
#parse and store transaction to database
#create Transaction object and save to DB
#obj =
description = transaction.get("entryDetails", {}).get("transactionDetails", {}).get(
"additionalRemittanceInformation")
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

View File

@@ -1,2 +1,5 @@
[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
packaging==25.0
pamqp==3.3.0
prometheus-fastapi-instrumentator==7.1.0
prometheus_client==0.23.1
prompt_toolkit==3.0.52
propcache==0.3.2
pwdlib==0.2.1
@@ -54,6 +56,7 @@ sentry-sdk==2.42.0
six==1.17.0
sniffio==1.3.1
SQLAlchemy==2.0.43
SQLAlchemy-Utils==0.42.0
starlette==0.48.0
tomli==2.2.1
typing-inspection==0.4.1
@@ -67,3 +70,4 @@ watchfiles==1.1.0
wcwidth==0.2.14
websockets==15.0.1
yarl==1.20.1
python-json-logger==2.0.7

View File

@@ -1,7 +1,9 @@
import sys
import uuid
import types
import pytest
from fastapi.testclient import TestClient
from httpx import AsyncClient, ASGITransport
# Stub sentry_sdk to avoid optional dependency issues during import of app
stub = types.ModuleType("sentry_sdk")
@@ -20,3 +22,23 @@ def fastapi_app():
@pytest.fixture(scope="session")
def client(fastapi_app):
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,179 @@
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:
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

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

View File

@@ -8,10 +8,12 @@ spec:
selector:
matchLabels:
app: {{ .Values.app.name }}
endpoint: metrics
template:
metadata:
labels:
app: {{ .Values.app.name }}
endpoint: metrics
spec:
containers:
- name: {{ .Values.app.name }}
@@ -101,6 +103,11 @@ spec:
secretKeyRef:
name: prod
key: SENTRY_DSN
- name: DB_ENCRYPTION_KEY
valueFrom:
secretKeyRef:
name: prod
key: DB_ENCRYPTION_KEY
livenessProbe:
httpGet:
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_USERNAME: {{ .Values.rabbitmq.username | 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
metadata:
name: {{ .Values.app.name }}
labels:
app: {{ .Values.app.name }}
spec:
ports:
- port: {{ .Values.service.port }}
- name: http
port: {{ .Values.service.port }}
targetPort: {{ .Values.app.port }}
selector:
app: {{ .Values.app.name }}

View File

@@ -20,7 +20,7 @@ spec:
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
drop: [ "ALL" ]
command:
- celery
- -A
@@ -80,3 +80,8 @@ spec:
secretKeyRef:
name: prod
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
secretName: app-demo-database-secret
password: ""
encryptionSecret: ""

View File

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

View File

@@ -9,7 +9,8 @@
"version": "0.0.0",
"dependencies": {
"react": "^19.1.1",
"react-dom": "^19.1.1"
"react-dom": "^19.1.1",
"recharts": "^3.3.0"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
@@ -1047,6 +1048,32 @@
"node": ">= 8"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.1.tgz",
"integrity": "sha512-sETJ3qO72y7L7WiR5K54UFLT3jRzAtqeBPVO15xC3bGA6kDqCH8m/v7BKCPH4czydXzz/1lPEGLvew7GjOO3Qw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^10.0.3",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.38",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz",
@@ -1362,6 +1389,18 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1407,6 +1446,69 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1435,7 +1537,7 @@
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.0.tgz",
"integrity": "sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@@ -1451,6 +1553,12 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.45.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.45.0.tgz",
@@ -1929,6 +2037,15 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -1982,9 +2099,130 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2003,6 +2241,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -2017,6 +2261,16 @@
"dev": true,
"license": "ISC"
},
"node_modules/es-toolkit": {
"version": "1.40.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.40.0.tgz",
"integrity": "sha512-8o6w0KFmU0CiIl0/Q/BCEOabF2IJaELM1T2PWj6e8KqzHv1gdx+7JtFnDwOx1kJH/isJ5NwlDG1nCr1HrRF94Q==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": {
"version": "0.25.10",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz",
@@ -2260,6 +2514,12 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2463,6 +2723,16 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.1.3",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz",
"integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -2490,6 +2760,15 @@
"node": ">=0.8.19"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -2925,6 +3204,36 @@
"react": "^19.2.0"
}
},
"node_modules/react-is": {
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz",
"integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==",
"license": "MIT",
"peer": true
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -2935,6 +3244,54 @@
"node": ">=0.10.0"
}
},
"node_modules/recharts": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.3.0.tgz",
"integrity": "sha512-Vi0qmTB0iz1+/Cz9o5B7irVyUjX2ynvEgImbgMt/3sKRREcUM07QiYjS1QpAVrkmVlXqy5gykq4nGWMz9AS4Rg==",
"license": "MIT",
"dependencies": {
"@reduxjs/toolkit": "1.x.x || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -3097,6 +3454,12 @@
"node": ">=8"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -3270,10 +3633,41 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "7.1.9",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz",
"integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==",
"version": "7.1.11",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz",
"integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@@ -11,7 +11,8 @@
},
"dependencies": {
"react": "^19.1.1",
"react-dom": "^19.1.1"
"react-dom": "^19.1.1",
"recharts": "^3.3.0"
},
"devDependencies": {
"@eslint/js": "^9.36.0",

View File

@@ -1 +0,0 @@
/* App-level styles moved to ui.css for a cleaner layout. */

View File

@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react';
import './App.css';
import LoginRegisterPage from './pages/LoginRegisterPage';
import Dashboard from './pages/Dashboard';
import { logout } from './api';

View File

@@ -16,8 +16,20 @@ export type Transaction = {
amount: number;
description?: string | null;
category_ids: number[];
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() {
const base = BACKEND_URL?.replace(/\/$/, '') || '';
return base || '';
@@ -84,6 +96,7 @@ export type CreateTransactionInput = {
amount: number;
description?: string;
category_ids?: number[];
date?: string; // YYYY-MM-DD
};
export async function createTransaction(input: CreateTransactionInput): Promise<Transaction> {
@@ -99,8 +112,13 @@ export async function createTransaction(input: CreateTransactionInput): Promise<
return res.json();
}
export async function getTransactions(): Promise<Transaction[]> {
const res = await fetch(`${getBaseUrl()}/transactions/`, {
export async function getTransactions(start_date?: string, end_date?: string): Promise<Transaction[]> {
const params = new URLSearchParams();
if (start_date) params.set('start_date', start_date);
if (end_date) params.set('end_date', end_date);
const qs = params.toString();
const url = `${getBaseUrl()}/transactions/${qs ? `?${qs}` : ''}`;
const res = await fetch(url, {
headers: getHeaders(),
});
if (!res.ok) throw new Error('Failed to load transactions');
@@ -153,3 +171,68 @@ export async function deleteMe(): Promise<void> {
export function logout() {
localStorage.removeItem('token');
}
// Categories
export type CreateCategoryInput = { name: string; description?: string };
export async function createCategory(input: CreateCategoryInput): Promise<Category> {
const res = await fetch(`${getBaseUrl()}/categories/create`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(input),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || 'Failed to create category');
}
return res.json();
}
export type UpdateCategoryInput = { name?: string; description?: string };
export async function updateCategory(category_id: number, input: UpdateCategoryInput): Promise<Category> {
const res = await fetch(`${getBaseUrl()}/categories/${category_id}`, {
method: 'PATCH',
headers: getHeaders(),
body: JSON.stringify(input),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || 'Failed to update category');
}
return res.json();
}
// Transactions update
export type UpdateTransactionInput = {
amount?: number;
description?: string;
date?: string;
category_ids?: number[];
};
export async function updateTransaction(id: number, input: UpdateTransactionInput): Promise<Transaction> {
const res = await fetch(`${getBaseUrl()}/transactions/${id}/edit`, {
method: 'PATCH',
headers: getHeaders(),
body: JSON.stringify(input),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || 'Failed to update transaction');
}
return res.json();
}
// Balance series
export type BalancePoint = { date: string; balance: number };
export async function getBalanceSeries(start_date?: string, end_date?: string): Promise<BalancePoint[]> {
const params = new URLSearchParams();
if (start_date) params.set('start_date', start_date);
if (end_date) params.set('end_date', end_date);
const qs = params.toString();
const url = `${getBaseUrl()}/transactions/balance_series${qs ? `?${qs}` : ''}`;
const res = await fetch(url, { headers: getHeaders() });
if (!res.ok) {
const text = await res.text();
throw new Error(text || 'Failed to load balance series');
}
return res.json();
}

View File

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

View File

@@ -1,2 +1,5 @@
export const BACKEND_URL: string =
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 {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}

View File

@@ -0,0 +1,66 @@
// src/BalanceChart.tsx
import { useEffect, useRef, useState } from 'react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from 'recharts';
import { type BalancePoint } from '../api';
function formatAmount(n: number) {
return new Intl.NumberFormat(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n);
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
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) {
return <div>No data to display</div>;
}
const desiredWidth = Math.max(containerWidth, Math.max(600, data.length * pxPerPoint));
return (
<div ref={wrapRef} className="chart-scroll">
<div className="chart-inner" style={{ minWidth: desiredWidth, paddingBottom: 8 }}>
<LineChart
width={desiredWidth}
height={300}
data={data}
margin={{ top: 5, right: 30, left: 50, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="date"
tickFormatter={formatDate}
label={{ value: 'Date', position: 'insideBottom', offset: -5 }}
/>
<YAxis
tickFormatter={(value) => formatAmount(value as number)}
label={{ value: 'Balance', angle: -90, position: 'insideLeft', offset: -30 }}
/>
<Tooltip
labelFormatter={formatDate}
formatter={(value) => [formatAmount(value as number), 'Balance']}
/>
<Legend />
<Line type="monotone" dataKey="balance" stroke="#3b82f6" strokeWidth={2} activeDot={{ r: 8 }} />
</LineChart>
</div>
</div>
);
}

View File

@@ -0,0 +1,104 @@
// src/CategoryPieCharts.tsx (renamed from CategoryPieChart.tsx)
import { useMemo } from 'react';
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { type Transaction, type Category } from '../api';
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#AF19FF', '#FF4242', '#8884d8', '#82ca9d'];
// Helper component for a single pie chart
function SinglePieChart({ data, title }: { data: { name: string; value: number }[]; title: string }) {
if (data.length === 0) {
return (
<div style={{ flex: 1, textAlign: 'center' }}>
<h4>{title}</h4>
<div>No data to display.</div>
</div>
);
}
return (
<div style={{ flex: 1 }}>
<h4>{title}</h4>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
labelLine={false}
outerRadius={80}
fill="#8884d8"
dataKey="value"
nameKey="name"
label={(props: any) => `${props.name} ${(props.percent * 100).toFixed(0)}%`}
>
{data.map((_entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip formatter={(value) => new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD' }).format(value as number)} />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
);
}
export default function CategoryPieCharts({ transactions, categories }: { transactions: Transaction[], categories: Category[] }) {
// Calculate expenses data
const expensesData = useMemo(() => {
const spendingMap = new Map<number, number>();
transactions.forEach(tx => {
// Expenses are typically negative amounts in your system
if (tx.amount < 0 && tx.category_ids.length > 0) {
tx.category_ids.forEach(catId => {
// Use absolute value for display on chart
spendingMap.set(catId, (spendingMap.get(catId) || 0) + Math.abs(tx.amount));
});
}
});
return Array.from(spendingMap.entries())
.map(([categoryId, total]) => ({
name: categories.find(c => c.id === categoryId)?.name || `Category #${categoryId}`,
value: total,
}))
.sort((a, b) => b.value - a.value); // Sort descending
}, [transactions, categories]);
// Calculate earnings data
const earningsData = useMemo(() => {
const incomeMap = new Map<number, number>();
transactions.forEach(tx => {
// Earnings are typically positive amounts in your system
if (tx.amount > 0 && tx.category_ids.length > 0) {
tx.category_ids.forEach(catId => {
incomeMap.set(catId, (incomeMap.get(catId) || 0) + tx.amount);
});
}
});
return Array.from(incomeMap.entries())
.map(([categoryId, total]) => ({
name: categories.find(c => c.id === categoryId)?.name || `Category #${categoryId}`,
value: total,
}))
.sort((a, b) => b.value - a.value); // Sort descending
}, [transactions, categories]);
return (
<div className="pie-grid" >
<div className="pie-card">
<SinglePieChart data={expensesData} title="Expenses by Category" />
</div>
<div className="pie-card">
<SinglePieChart data={earningsData} title="Earnings by Category" />
</div>
</div>
);
}

View File

@@ -1,19 +1,162 @@
import { useEffect, useMemo, useState } from 'react';
import { type Category, type Transaction, createTransaction, getCategories, getTransactions } from '../api';
import { type Category, type Transaction, type BalancePoint, deleteTransaction, getCategories, getTransactions, createTransaction, updateTransaction, getBalanceSeries } from '../api';
import AccountPage from './AccountPage';
import AppearancePage from './AppearancePage';
import { BACKEND_URL } from '../config';
import BalanceChart from './BalanceChart';
import ManualManagement from './ManualManagement';
import CategoryPieChart from './CategoryPieChart';
import MockBankModal, { type MockGenerationOptions } from './MockBankModal';
import { BACKEND_URL, VITE_UNIRATE_API_KEY } from '../config';
function formatAmount(n: number) {
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 }) {
const [current, setCurrent] = useState<'home' | 'account' | 'appearance'>('home');
const [current, setCurrent] = useState<'home' | 'manual' | 'account' | 'appearance'>('home');
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isMockModalOpen, setMockModalOpen] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
// Start CSAS (George) OAuth after login
async function startOauthCsas() {
@@ -36,24 +179,51 @@ 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
const [minAmount, setMinAmount] = useState<string>('');
const [maxAmount, setMaxAmount] = useState<string>('');
const [filterCategoryId, setFilterCategoryId] = useState<number | ''>('');
const [searchText, setSearchText] = useState('');
// Date-range filter
const [startDate, setStartDate] = useState<string>(''); // YYYY-MM-DD
const [endDate, setEndDate] = useState<string>('');
// Pagination over filtered transactions (20 per page), 0 = latest (most recent)
const pageSize = 20;
const [page, setPage] = useState<number>(0);
// Balance chart series for current date filter
const [balanceSeries, setBalanceSeries] = useState<BalancePoint[]>([]);
// Manual forms moved to ManualManagement page
// Inline edit state for transaction editing
const [editingTxId, setEditingTxId] = useState<number | null>(null);
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() {
setLoading(true);
setError(null);
try {
const [txs, cats] = await Promise.all([getTransactions(), getCategories()]);
const [txs, cats, series] = await Promise.all([
getTransactions(startDate || undefined, endDate || undefined),
getCategories(),
getBalanceSeries(startDate || undefined, endDate || undefined),
]);
setTransactions(txs);
setCategories(cats);
setBalanceSeries(series);
// reset paging to most recent
setPage(0);
} catch (err: any) {
setError(err?.message || 'Failed to load data');
} finally {
@@ -61,15 +231,54 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
}
}
useEffect(() => { loadAll(); }, []);
async function handleGenerateMockTransactions(options: MockGenerationOptions) {
setIsGenerating(true);
setMockModalOpen(false);
const last10 = useMemo(() => {
const sorted = [...transactions].sort((a, b) => b.id - a.id);
return sorted.slice(0, 10);
}, [transactions]);
const { count, minAmount, maxAmount, startDate, endDate, categoryIds } = options;
const newTransactions: Transaction[] = [];
const startDateTime = new Date(startDate).getTime();
const endDateTime = new Date(endDate).getTime();
for (let i = 0; i < count; i++) {
// Generate random data based on user input
const amount = parseFloat((Math.random() * (maxAmount - minAmount) + minAmount).toFixed(2));
const randomTime = Math.random() * (endDateTime - startDateTime) + startDateTime;
const date = new Date(randomTime);
const dateString = date.toISOString().split('T')[0];
const randomCategory = categoryIds.length > 0
? [categoryIds[Math.floor(Math.random() * categoryIds.length)]]
: [];
const payload = {
amount,
date: dateString,
category_ids: randomCategory,
};
try {
const created = await createTransaction(payload);
newTransactions.push(created);
} catch (err) {
console.error("Failed to create mock transaction:", err);
alert('An error occurred while generating transactions. Check the console.');
break;
}
}
setIsGenerating(false);
alert(`${newTransactions.length} mock transactions were successfully generated!`);
await loadAll();
}
useEffect(() => { loadAll(); }, [startDate, endDate]);
const filtered = useMemo(() => {
let arr = last10;
let arr = [...transactions];
const min = minAmount !== '' ? Number(minAmount) : undefined;
const max = maxAmount !== '' ? Number(maxAmount) : undefined;
if (min !== undefined) arr = arr.filter(t => t.amount >= min);
@@ -77,40 +286,96 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
if (filterCategoryId !== '') arr = arr.filter(t => t.category_ids.includes(filterCategoryId as number));
if (searchText.trim()) arr = arr.filter(t => (t.description || '').toLowerCase().includes(searchText.toLowerCase()));
return arr;
}, [last10, minAmount, maxAmount, filterCategoryId, searchText]);
}, [transactions, minAmount, maxAmount, filterCategoryId, searchText]);
const sortedDesc = useMemo(() => {
return [...filtered].sort((a, b) => {
const ad = (a.date || '') > (b.date || '') ? 1 : (a.date || '') < (b.date || '') ? -1 : 0;
if (ad !== 0) return -ad; // date desc
return b.id - a.id; // fallback id desc
});
}, [filtered]);
const totalPages = Math.ceil(sortedDesc.length / pageSize);
const pageStart = page * pageSize;
const pageEnd = pageStart + pageSize;
const visible = sortedDesc.slice(pageStart, pageEnd);
function categoryNameById(id: number) { return categories.find(c => c.id === id)?.name || `#${id}`; }
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,
};
function beginEditTransaction(t: Transaction) {
setEditingTxId(t.id);
setEditingCategoryIds([...(t.category_ids || [])]);
setEditingAmount(String(t.amount));
setEditingDescription(t.description || '');
setEditingDate(t.date || '');
}
function cancelEditTransaction() {
setEditingTxId(null);
setEditingCategoryIds([]);
setEditingAmount('');
setEditingDescription('');
setEditingDate('');
}
async function saveEditTransaction() {
if (editingTxId == null) return;
const amountNum = Number(editingAmount);
if (Number.isNaN(amountNum)) {
alert('Amount must be a number.');
return;
}
try {
const created = await createTransaction(payload);
setTransactions(prev => [created, ...prev]);
setAmount(''); setDescription(''); setSelectedCategoryId('');
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)));
// Optionally refresh balance series to reflect changes immediately
try { setBalanceSeries(await getBalanceSeries(startDate || undefined, endDate || undefined)); } catch {}
cancelEditTransaction();
} catch (err: any) {
alert(err?.message || 'Failed to create transaction');
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 (
<div className="app-layout">
<aside className="sidebar">
<div className="logo">7Project</div>
<nav className="nav">
<button className={current === 'home' ? 'active' : ''} onClick={() => setCurrent('home')}>Home</button>
<button className={current === 'account' ? 'active' : ''} onClick={() => setCurrent('account')}>Account</button>
<button className={current === 'appearance' ? 'active' : ''} onClick={() => setCurrent('appearance')}>Appearance</button>
</nav>
<div className={`app-layout ${sidebarOpen ? 'sidebar-open' : ''}`}>
<aside className="sidebar" style={{ display: 'flex', flexDirection: 'column' }}>
<div>
<div className="logo">Finance Tracker</div>
<nav className="nav" onClick={() => setSidebarOpen(false)}>
<button className={current === 'home' ? 'active' : ''} onClick={() => setCurrent('home')}>Home</button>
<button className={current === 'manual' ? 'active' : ''} onClick={() => setCurrent('manual')}>Manual management</button>
<button className={current === 'account' ? 'active' : ''} onClick={() => setCurrent('account')}>Account</button>
<button className={current === 'appearance' ? 'active' : ''} onClick={() => setCurrent('appearance')}>Appearance</button>
</nav>
</div>
<CurrencyRates />
</aside>
<div className="content">
<div className="topbar">
<h2 style={{ margin: 0 }}>{current === 'home' ? 'Dashboard' : current === '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">
<span className="user muted">Signed in</span>
<button className="btn" onClick={onLogout}>Logout</button>
@@ -119,28 +384,25 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
<main className="page space-y">
{current === 'home' && (
<>
<section className="card">
<h3>Bank connections</h3>
<p className="muted">Connect your CSAS (George) account.</p>
<button className="btn" onClick={startOauthCsas}>Connect CSAS (George)</button>
<section className="card space-y">
<h3>Bank connections</h3>
<div className="connection-row">
<p className="muted" style={{ margin: 0 }}>Connect your CSAS (George) account.</p>
<button className="btn primary" onClick={startOauthCsas}>Connect CSAS (George)</button>
</div>
<div className="connection-row">
<p className="muted" style={{ margin: 0 }}>Generate data from a mock bank.</p>
<button className="btn primary" onClick={() => setMockModalOpen(true)}>Connect Mock Bank</button>
</div>
</section>
<section className="card">
<h3>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="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>Filters</h3>
<div className="form-row">
<div className="form-row" style={{ gap: 8, flexWrap: 'wrap' }}>
<input className="input" type="date" placeholder="Start date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
<input className="input" type="date" placeholder="End date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
<input className="input" type="number" step="0.01" placeholder="Min amount" value={minAmount} onChange={(e) => setMinAmount(e.target.value)} />
<input className="input" type="number" step="0.01" placeholder="Max amount" value={maxAmount} onChange={(e) => setMaxAmount(e.target.value)} />
<select className="input" value={filterCategoryId} onChange={(e) => setFilterCategoryId(e.target.value ? Number(e.target.value) : '')}>
@@ -152,7 +414,30 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
</section>
<section className="card">
<h3>Latest Transactions (last 10)</h3>
<h3>Balance over time</h3>
{loading ? (
<div>Loading</div>
) : error ? (
<div style={{ color: 'crimson' }}>{error}</div>
) : (
<BalanceChart data={balanceSeries} />
)}
</section>
{/* 3. Add the new section for the Category Pie Chart */}
<section className="card">
{loading ? (
<div>Loading</div>
) : error ? (
<div style={{ color: 'crimson' }}>{error}</div>
) : (
// Pass the filtered transactions to see the breakdown for the current view
<CategoryPieChart transactions={filtered} categories={categories} />
)}
</section>
<section className="card">
<h3>Transactions</h3>
{loading ? (
<div>Loading</div>
) : error ? (
@@ -160,26 +445,116 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
) : filtered.length === 0 ? (
<div>No transactions</div>
) : (
<table className="table">
<thead>
<tr>
<th>ID</th>
<th style={{ textAlign: 'right' }}>Amount</th>
<th>Description</th>
<th>Categories</th>
</tr>
</thead>
<tbody>
{filtered.map(t => (
<tr key={t.id}>
<td>{t.id}</td>
<td className="amount">{formatAmount(t.amount)}</td>
<td>{t.description || ''}</td>
<td>{t.category_ids.map(id => categoryNameById(id)).join(', ')}</td>
<>
<div className="table-controls">
<div className="muted">
Showing {visible.length} of {filtered.length} (page {Math.min(page + 1, Math.max(1, totalPages))}/{Math.max(1, totalPages)})
</div>
<div className="actions">
<button className="btn primary" disabled={page <= 0} onClick={() => setPage(p => Math.max(0, p - 1))}>Previous</button>
<button className="btn primary" disabled={page >= totalPages - 1} onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}>Next</button>
</div>
</div>
<table className="table responsive">
<thead>
<tr>
<th>Date</th>
<th style={{ textAlign: 'right' }}>Amount</th>
<th>Description</th>
<th>Categories</th>
<th>Actions</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{visible.map(t => (
<tr key={t.id}>
{/* Date cell */}
<td data-label="Date">
{editingTxId === t.id ? (
<input
className="input"
type="date"
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 => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
) : (
<span>{t.category_ids.map(id => categoryNameById(id)).join(', ') || '—'}</span>
)}
</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>
)}
</td>
</tr>
))}
</tbody>
</table>
</>
)}
</section>
</>
@@ -190,11 +565,27 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
<AccountPage onDeleted={onLogout} />
)}
{current === 'manual' && (
<ManualManagement
categories={categories}
onTransactionAdded={(t) => setTransactions(prev => [t, ...prev])}
onCategoryCreated={(c) => setCategories(prev => [...prev, c])}
/>
)}
{current === 'appearance' && (
<AppearancePage />
)}
</main>
</div>
<MockBankModal
isOpen={isMockModalOpen}
isGenerating={isGenerating}
categories={categories}
onClose={() => setMockModalOpen(false)}
onGenerate={handleGenerateMockTransactions}
/>
{sidebarOpen && <div className="backdrop" onClick={() => setSidebarOpen(false)} />}
</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)} />
</div>
{mode === 'register' && (
<div className="form-row">
<div className="space-y">
<div>
<label className="muted">First name (optional)</label>
<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,27 +31,76 @@ body[data-theme="dark"] {
}
/* Layout */
.app-layout { display: grid; grid-template-columns: 260px 1fr; height: 100%; }
.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 .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 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; }
.content { display: flex; flex-direction: column; height: 100%; }
.topbar { height: 64px; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; background: var(--panel); border-bottom: 1px solid var(--border); }
.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 .user { color: var(--muted); }
.page { padding: 24px; max-width: 1100px; margin: auto; }
.page { padding: 24px; }
/* Cards */
.card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 16px; }
.card h3 { margin: 0 0 12px; }
/* Forms */
.input, select, textarea { width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--border); background: #fff; color: var(--text); }
.input:focus, select:focus, textarea:focus { outline: 2px solid var(--primary); border-color: var(--primary); }
/* Common field styles (no custom arrow here) */
.input, textarea {
width: 100%;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--border);
background-color: var(--panel);
color: var(--muted);
}
/* Select-only: show custom dropdown arrow */
select.input {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
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-position: right 0.5rem center;
background-repeat: no-repeat;
background-size: 1.5em 1.5em;
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 {
outline: 2px solid var(--primary);
outline-offset: 2px;
border-color: var(--primary);
}
.form-row { display: grid; gap: 8px; grid-template-columns: repeat(4, minmax(0,1fr)); }
.form-row > * { min-width: 140px; }
.form-row > .btn {
justify-self: start;
}
.actions { display: flex; align-items: center; gap: 8px; }
/* Buttons */
@@ -59,12 +108,25 @@ body[data-theme="dark"] {
.btn.primary { background: var(--primary); border-color: var(--primary); color: #fff; }
.btn.primary:hover { background: var(--primary-600); }
.btn.ghost { background: transparent; color: var(--muted); }
.btn, .input, select, textarea, .nav a, .nav button, .segmented button {
transition: all 0.2s ease-in-out;
}
.btn.small {
padding: 4px 10px;
font-size: 0.875rem; /* 14px */
}
/* Tables */
.table { width: 100%; border-collapse: collapse; }
.table th, .table td { padding: 10px; border-bottom: 1px solid var(--border); }
.table th { text-align: left; color: var(--muted); font-weight: 600; }
.table td.amount { text-align: right; font-variant-numeric: tabular-nums; }
.table-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px; /* Adds some space above the table */
}
/* Segmented control */
.segmented { display: inline-flex; background: #f1f5f9; border-radius: 10px; padding: 4px; border: 1px solid var(--border); }
@@ -83,3 +145,146 @@ body.auth-page #root {
/* Utility */
.muted { color: var(--muted); }
.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/
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
- Date: 2025-10-08
- Date: 2025-10-16
- Attendees: Dejan Ribarovski, Lukas Trkan
- Notetaker: Dejan Ribarovski
@@ -43,8 +43,8 @@ Prepare 3-5 questions and topics you want to discuss with your mentor.
Last 3 minutes of the meeting, summarize action items.
- [ ] OAuth
- [ ] CI/CD fix
- [x] OAuth
- [x] CI/CD fix
- [ ] Database local (multiple bank accounts)
- [ ] Add tests and set up github pipeline
- [ ] Frontend imporvment - user experience

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.
- [ ] 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

@@ -14,7 +14,7 @@
- 289229, Lukáš Trkan, lukastrkan
- 289258, Dejan Ribarovski, derib2613, ribardej
**Brief Description**:
**Brief Description**: (něco spíš jako abstract, introuction, story behind)
Our application is a finance tracker, so a person can easily track his cash flow
through multiple bank accounts. Person can label transactions with custom categories
and later filter by them.
@@ -52,46 +52,45 @@ flowchart LR
- Backend: Python, FastAPI, FastAPI Users, SQLAlchemy, Pydantic, Alembic, Celery
- Frontend: React, TypeScript, Vite
- Database: PostgreSQL
- Messaging: RabbitMQ
- Cache: Redis
- Database: MariaDB (Maxscale)
- Background jobs: RabbitMQ, Celery
- Containerization/Orchestration: Docker, Docker Compose (dev), Kubernetes, Helm
- IaC/Platform: OpenTofu (Terraform), Argo CD, cert-manager, MetalLB, Cloudflare Tunnel, Prometheus
- IaC/Platform: Proxmox, Talos, Cloudflare pages, OpenTofu (Terraform), cert-manager, MetalLB, Cloudflare Tunnel, Prometheus, Loki
## Prerequisites
### System Requirements
- Operating System: Linux, macOS, or Windows
- 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)
- Storage: 2 GB free (Docker images may require additional space)
- Storage: 4 GB free (Docker images may require additional space)
### Required Software
- Docker Desktop or Docker Engine 24+
- Docker Compose v2+
- Node.js 20+ and npm 10+ (for local frontend dev/build)
- Python 3.12+ (for local backend dev outside Docker)
- PostgreSQL 15+ (optional if running DB outside Docker)
- Helm 3.12+ and kubectl 1.29+ (for Kubernetes deployment)
- OpenTofu 1.7+ (for infrastructure provisioning)
- Docker Desktop or Docker Engine
- Docker Compose
- Node.js and npm
- Python 3.12+
- MariaDB 11
- Helm 3.12+ and kubectl 1.29+
- OpenTofu
### Environment Variables (common)
# TODO: UPDATE
- Backend: SECRET, FRONTEND_URL, BACKEND_URL, DATABASE_URL, RABBITMQ_URL, REDIS_URL
- OAuth vars (Backend): MOJEID_CLIENT_ID/SECRET, BANKID_CLIENT_ID/SECRET (optional)
- Frontend: VITE_BACKEND_URL
### 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
Frontend: React, TypeScript, Vite
Services: PostgreSQL, RabbitMQ, Redis
## Local development
## Build Instructions
You can run the project with Docker Compose (recommended for local development) or run services manually.
You can run the project with Docker Compose and Python virtual environment for testing and dev purposes
### 1) Clone the Repository
@@ -103,9 +102,8 @@ cd 7project
### 2) Install dependencies
Backend
```bash
# In 7project/backend
python3.12 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```
Frontend
@@ -120,9 +118,10 @@ Backend
```bash
# From the 7project/ directory
docker compose up --build
# This starts: PostgreSQL, RabbitMQ/Redis (if defined)
# This starts: MariaDB, RabbitMQ
# Set environment variables (or create .env file)
# TODO: fix
export SECRET=CHANGE_ME_SECRET
export BACKEND_URL=http://127.0.0.1:8000
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
# Apply DB migrations (Alembic)
# From 7project/backend
alembic upgrade head
# From 7project
bash upgrade_database.sh
# Run API
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
```
@@ -152,18 +150,22 @@ npm run dev
- Backend default: http://127.0.0.1:8000 (OpenAPI at /docs)
- 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
### Local (Docker Compose)
Described in the previous section (Manual Local Run)
### Kubernetes (via OpenTofu + Helm)
1) Provision platform services (RabbitMQ/Redis/ingress/tunnel/etc.) with OpenTofu
1) Install base services to cluster
```bash
cd tofu
# 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] |
| [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] |
| [Database Setup & Models](https://github.com/dat515-2025/Group-8/tree/main/7project/backend/app/models) | Lukas | ✅ Complete | [X 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 | 🔄 In Progress | [X hours] | Medium | [Any notes] |
| [Frontend Development](https://github.com/dat515-2025/Group-8/tree/main/7project/frontend) | Dejan | 🔄 In Progress | 7 hours so far | Medium | [Any notes] |
| [Docker Configuration](https://github.com/dat515-2025/Group-8/blob/main/7project/compose.yml) | Lukas | ✅ Complete | [X hours] | Easy | [Any notes] |
| [Cloud Deployment](https://github.com/dat515-2025/Group-8/blob/main/7project/deployment/app-demo-deployment.yaml) | Lukas | ✅ Complete | [X hours] | Hard | [Any notes] |
| [Testing Implementation](https://github.com/dat515-2025/group-name) | Dejan | ❌ Not Started | [X hours] | Medium | [Any notes] |
| [Documentation](https://github.com/dat515-2025/group-name) | Both | ❌ Not Started | [X hours] | Easy | [Any notes] |
| [Testing Implementation](https://github.com/dat515-2025/group-name) | Dejan | 🔄 In Progress | [X hours] | Medium | [Any notes] |
| [Documentation](https://github.com/dat515-2025/group-name) | Both | 🔄 In Progress | [X hours] | Easy | [Any notes] |
| [Presentation Video](https://github.com/dat515-2025/group-name) | Both | ❌ Not Started | [X hours] | Medium | [Any notes] |
**Legend**: ✅ Complete | 🔄 In Progress | ⏳ Pending | ❌ Not Started
@@ -338,25 +340,28 @@ curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8000/authenticated-route
> Link to the specific commit on GitHub for each contribution.
### [Team Member 1 Name]
### [Lukáš]
| Date | Activity | Hours | Description |
| --------- | ------------------- | ---------- | ----------------------------------- |
| [Date] | Initial Setup | [X.X] | Repository setup, project structure |
| [Date] | Backend Development | [X.X] | Implemented user authentication |
| [Date] | Testing | [X.X] | Unit tests for API endpoints |
| [Date] | Documentation | [X.X] | Updated README and design doc |
| **Total** | | **[XX.X]** | |
| Date | Activity | Hours | Description |
|----------------|---------------------|------------|----------------------------------------------------|
| 4.10 to 10.10 | Initial Setup | 40 | Repository setup, project structure, cluster setup |
| 14.10 to 16.10 | Backend Development | 12 | Implemented user authentication - oauth |
| 8.10 to 12.10 | CI/CD | 10 | Created database schema and models |
| [Date] | Testing | [X.X] | Unit tests for API endpoints |
| [Date] | Documentation | [X.X] | Updated README and design doc |
| **Total** | | **[XX.X]** | |
### Dejan
| Date | Activity | Hours | Description |
|-------------|----------------------|--------|--------------------------------|
| 25.9. | Design | 1.5 | 6design |
| 9-11.10. | Backend APIs | 10 | Implemented Backend APIs |
| 13-15.10. | Frontend Development | 6.5 | Created user interface mockups |
| Continually | Documantation | 3 | Documenting the dev process |
| **Total** | | **21** | |
| Date | Activity | Hours | Description |
|-----------------|----------------------|--------|----------------------------------------------------------------------------------|
| 25.9. | Design | 2 | 6design |
| 9.10 to 11.10. | Backend APIs | 10 | Implemented Backend APIs |
| 13.10 to 15.10. | Frontend Development | 7 | Created user interface mockups |
| Continually | Documantation | 5 | Documenting the dev process |
| 21.10 to 23.10 | Tests, forntend | 10 | Test basics, balance charts, and frontend improvement |
| 28.10 to 30.10 | Tests, forntend | 7 | Tests improvement with test database setup, UI fix and exchange rate integration |
| **Total** | | **41** | |
### Group Total: [XXX.X] hours

View File

@@ -64,3 +64,21 @@ resource "kubectl_manifest" "argocd-tunnel-bind" {
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