Compare commits

95 Commits

Author SHA1 Message Date
188cdf5727 Update .github/workflows/deploy-prod.yaml
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-11 15:03:53 +01:00
4cf0d2a981 Update 7project/charts/myapp-chart/templates/prod.yaml
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-11 15:03:43 +01:00
9986cce8f9 Update 7project/charts/myapp-chart/values.yaml
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-11 15:03:36 +01:00
b3b5717e9e feat(infrastructure): add email sender 2025-11-11 14:59:28 +01:00
537d050080 feat(deployment): add 404 for public access 2025-11-11 14:16:08 +01:00
1e4f342176 feat(deployment): add cron support 2025-11-11 14:07:33 +01:00
c62e0adcf3 feat(deployment): add cron support 2025-11-11 14:03:31 +01:00
24d86abfc4 feat(deployment): add cron support 2025-11-11 13:58:36 +01:00
21305f18e2 feat(deployment): add cron support 2025-11-11 13:54:45 +01:00
e708f7b18b feat(deployment): add cron support 2025-11-11 13:52:17 +01:00
f58083870f Merge pull request #46 from dat515-2025/merge/prometheus_custom_metrics
Some checks failed
Deploy Prod / Run Python Tests (push) Has been cancelled
Deploy Prod / Build and push image (reusable) (push) Has been cancelled
Deploy Prod / Generate Production URLs (push) Has been cancelled
Deploy Prod / Frontend - Build and Deploy to Cloudflare Pages (prod) (push) Has been cancelled
Deploy Prod / Helm upgrade/install (prod) (push) Has been cancelled
feat(prometheus): add custom metrics
2025-11-09 12:54:52 +01:00
ca8287cd8b feat(prometheus): add custom metrics 2025-11-09 12:43:27 +01:00
ribardej
ed3e6329dd feat(docs): new metting.md
Some checks failed
Deploy Prod / Run Python Tests (push) Has been cancelled
Deploy Prod / Build and push image (reusable) (push) Has been cancelled
Deploy Prod / Generate Production URLs (push) Has been cancelled
Deploy Prod / Frontend - Build and Deploy to Cloudflare Pages (prod) (push) Has been cancelled
Deploy Prod / Helm upgrade/install (prod) (push) Has been cancelled
2025-11-06 13:13:16 +01:00
ribardej
a214e2cd8b fix(test): fixed tests for local usage and documentation in report.md 2025-11-06 12:28:42 +01:00
6c8d2202b5 update report 2025-11-06 12:03:15 +01:00
Dejan Ribarovski
b480734fee Merge pull request #45 from dat515-2025/add_more_tests
feat(test): added more tests
2025-11-06 11:31:07 +01:00
ribardej
8b301c386e feat(test): added more tests 2025-11-06 11:20:10 +01:00
ribardej
733e7a8918 feat(test): added more tests 2025-11-06 11:14:57 +01:00
ribardej
524e7a6f98 fix(frontend): fixed exchange rates and app name 2025-11-06 09:56:16 +01:00
ribardej
0c9882e9b3 feat(frontend): fixed exchange rates
Some checks are pending
Deploy Prod / Run Python Tests (push) Waiting to run
Deploy Prod / Build and push image (reusable) (push) Waiting to run
Deploy Prod / Generate Production URLs (push) Waiting to run
Deploy Prod / Frontend - Build and Deploy to Cloudflare Pages (prod) (push) Blocked by required conditions
Deploy Prod / Helm upgrade/install (prod) (push) Blocked by required conditions
2025-11-05 23:14:12 +01:00
Dejan Ribarovski
72494c4aae Merge pull request #44 from dat515-2025/43-fix-the-ui-layout-in-chrome
Fixed the layout issues for Chrome-based browsers, added options for users modifying transactions in the UI and implemented mobile friendly UI responsiveness
2025-11-05 20:42:38 +01:00
Dejan Ribarovski
60560dea99 Update 7project/frontend/src/pages/Dashboard.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-05 20:39:52 +01:00
ribardej
a9b2aba55a feat(frontend): implemented mobile friendly UI responsiveness 2025-11-05 20:24:33 +01:00
ribardej
36b1fe887b feat(frontend): Added options for modifying and deleting transactions in the UI 2025-11-05 18:00:24 +01:00
ribardej
8543c72730 fix(frontend): fixed the layout for chrome based browsers 2025-11-05 15:49:31 +01:00
24087c2810 updated report 2025-11-02 22:59:12 +01:00
ribardej
6818b1f649 fix(frontend): CNB API fix
Some checks failed
Deploy Prod / Run Python Tests (push) Has been cancelled
Deploy Prod / Build and push image (reusable) (push) Has been cancelled
Deploy Prod / Generate Production URLs (push) Has been cancelled
Deploy Prod / Frontend - Build and Deploy to Cloudflare Pages (prod) (push) Has been cancelled
Deploy Prod / Helm upgrade/install (prod) (push) Has been cancelled
2025-10-30 22:37:32 +01:00
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
ribardej
e78b8c2e6b fix(tests): fixed a service test and one warning regarding 422 status
Some checks failed
Run Python Tests / build-and-test (push) Has been cancelled
2025-10-22 14:53:45 +02:00
ribardej
aade88beb9 fix(backend): added a default value for FRONTEND_DOMAIN_SCHEME so the tests dont crash 2025-10-22 14:48:56 +02:00
Dejan Ribarovski
5305531950 Merge branch 'main' into 30-create-tests-and-set-up-a-github-pipeline 2025-10-22 14:39:28 +02:00
ribardej
6d8a6a55c0 fix(backend): refactored app to fastApi to avoid CORS errors 2025-10-22 14:37:48 +02:00
396047574a fix(auth): increase timeout
Some checks are pending
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-21 22:43:30 +02:00
d926168ef9 fix(oauth): use prod MojeID 2025-10-21 22:24:39 +02:00
41956b8e0c Merge pull request #32 from dat515-2025/merge/fix_react_oauth
feat(oauth): add csas connection, allow oauth from react
2025-10-21 22:13:54 +02:00
9734895758 feat(oauth): add to env 2025-10-21 22:11:32 +02:00
91a32b2f10 feat(oauth): add to env 2025-10-21 22:08:00 +02:00
2b640fc6ac Update 7project/backend/app/oauth/csas.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-21 22:03:13 +02:00
3ebf47e371 feat(oauth): add csas connection, allow oauth from react 2025-10-21 22:01:09 +02:00
Dejan Ribarovski
40d07677bd Potential fix for code scanning alert no. 11: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-10-21 15:57:23 +02:00
ribardej
76eb2cce41 feat(tests): Added tests to PR and Prod workflows 2025-10-21 15:44:33 +02:00
ribardej
391e9da0c4 feat(tests): Implemented basic tests and github workflow 2025-10-21 15:36:49 +02:00
65 changed files with 3573 additions and 280 deletions

View File

@@ -9,6 +9,11 @@ permissions:
pull-requests: write
jobs:
test:
name: Run Python Tests
if: github.event.action != 'closed'
uses: ./.github/workflows/run-tests.yml
build:
if: github.event.action != 'closed'
name: Build and push image (reusable)
@@ -95,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

@@ -21,6 +21,10 @@ concurrency:
cancel-in-progress: false
jobs:
test:
name: Run Python Tests
uses: ./.github/workflows/run-tests.yml
build:
name: Build and push image (reusable)
uses: ./.github/workflows/build-image.yaml
@@ -85,7 +89,16 @@ jobs:
BANKID_CLIENT_SECRET: ${{ secrets.BANKID_CLIENT_SECRET }}
MOJEID_CLIENT_ID: ${{ secrets.MOJEID_CLIENT_ID }}
MOJEID_CLIENT_SECRET: ${{ secrets.MOJEID_CLIENT_SECRET }}
CSAS_CLIENT_ID: ${{ secrets.CSAS_CLIENT_ID }}
CSAS_CLIENT_SECRET: ${{ secrets.CSAS_CLIENT_SECRET }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SMTP_HOST: ${{ secrets.SMTP_HOST }}
SMTP_PORT: ${{ secrets.SMTP_PORT }}
SMTP_USERNAME: ${{ secrets.SMTP_USERNAME }}
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
SMTP_USE_TLS: ${{ secrets.SMTP_USE_TLS }}
SMTP_USE_SSL: ${{ secrets.SMTP_USE_SSL }}
SMTP_FROM: ${{ secrets.SMTP_FROM }}
run: |
helm upgrade --install myapp ./7project/charts/myapp-chart \
-n prod --create-namespace \
@@ -102,4 +115,14 @@ jobs:
--set-string oauth.bankid.clientSecret="$BANKID_CLIENT_SECRET" \
--set-string oauth.mojeid.clientId="$MOJEID_CLIENT_ID" \
--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 database.encryptionSecret="${{ secrets.PROD_DB_ENCRYPTION_KEY }}" \
--set-string smtp.host="$SMTP_HOST" \
--set smtp.port="$SMTP_PORT" \
--set-string smtp.username="$SMTP_USERNAME" \
--set-string smtp.password="$SMTP_PASSWORD" \
--set-string smtp.tls="$SMTP_USE_TLS" \
--set-string smtp.ssl="$SMTP_USE_SSL" \
--set-string smtp.from="$SMTP_FROM"

61
.github/workflows/run-tests.yml vendored Normal file
View File

@@ -0,0 +1,61 @@
name: Run Python Tests
permissions:
contents: read
on:
workflow_call:
jobs:
build-and-test:
runs-on: ubuntu-latest
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:
- 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: 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
- name: Install dependencies
run: |
python -m pip install --upgrade pip
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 config to user
Revision ID: eabec90a94fe
Revises: 5ab2e654c96e
Create Date: 2025-10-21 18:56:42.085973
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'eabec90a94fe'
down_revision: Union[str, Sequence[str], None] = '5ab2e654c96e'
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('user', sa.Column('config', sa.JSON(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('user', 'config')
# ### end Alembic commands ###

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

@@ -0,0 +1,40 @@
import json
import os
from fastapi import APIRouter
from fastapi.params import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.oauth.csas import CSASOAuth
from app.services.db import get_async_session
from app.services.user_service import current_active_user
router = APIRouter(prefix="/auth/csas", tags=["csas"])
CLIENT_ID = os.getenv("CSAS_CLIENT_ID")
CLIENT_SECRET = os.getenv("CSAS_CLIENT_SECRET")
CSAS_OAUTH = CSASOAuth(CLIENT_ID, CLIENT_SECRET)
@router.get("/authorize")
async def csas_authorize():
return {"authorization_url":
await CSAS_OAUTH.get_authorization_url(os.getenv("FRONTEND_DOMAIN_SCHEME") + "/auth/csas/callback")}
@router.get("/callback")
async def csas_callback(code: str, session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user)):
response = await CSAS_OAUTH.get_access_token(code, os.getenv("FRONTEND_DOMAIN_SCHEME") + "/auth/csas/callback")
if not user.config:
user.config = {}
new_dict = user.config.copy()
new_dict["csas"] = json.dumps(response)
user.config = new_dict
await session.commit()
return "OK"

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,29 +1,41 @@
import json
import logging
import os
import sys
from datetime import datetime
from pythonjsonlogger import jsonlogger
from fastapi import Depends, FastAPI
from fastapi import Depends, FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from prometheus_fastapi_instrumentator import Instrumentator, metrics
from starlette.requests import Request
from app.models.user import User
from app.services.prometheus import number_of_users, number_of_transactions
from app.services import bank_scraper
from app.workers.celery_tasks import load_transactions, load_all_transactions
from app.models.user import User, OAuthAccount
from app.services.user_service import current_active_verified_user
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
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
from fastapi_users.db import SQLAlchemyUserDatabase
from app.core.db import async_session_maker
sentry_sdk.init(
dsn=os.getenv("SENTRY_DSN"),
send_default_pii=True,
)
app = FastAPI()
fastApi = FastAPI()
# CORS for frontend dev server
@@ -39,11 +51,56 @@ fastApi.add_middleware(
allow_headers=["*"],
)
prometheus = Instrumentator().instrument(fastApi)
# Register custom metrics
prometheus.add(number_of_users()).add(number_of_transactions())
prometheus.expose(
fastApi,
endpoint="/metrics",
include_in_schema=True,
)
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()
@@ -61,15 +118,17 @@ 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"),
auth_backend,
"SECRET",
associate_by_email=True,
redirect_url=os.getenv("FRONTEND_DOMAIN_SCHEME", "http://localhost:3000") + "/auth/mojeid/callback",
),
prefix="/auth/mojeid",
tags=["auth"],
@@ -81,11 +140,14 @@ fastApi.include_router(
auth_backend,
"SECRET",
associate_by_email=True,
redirect_url=os.getenv("FRONTEND_DOMAIN_SCHEME", "http://localhost:3000") + "/auth/bankid/callback",
),
prefix="/auth/bankid",
tags=["auth"],
)
fastApi.include_router(csas_router)
# Liveness/root endpoint
@fastApi.get("/", include_in_schema=False)
@@ -97,6 +159,13 @@ 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("/_cron", include_in_schema=False)
async def handle_cron(request: Request):
# endpoint accessed by Clodflare => return 404
if request.headers.get("cf-connecting-ip"):
raise HTTPException(status_code=404)
logging.info("[Cron] Triggering scheduled tasks via HTTP endpoint")
task = load_all_transactions.delay()
return {"status": "queued", "action": "csas_scrape_all", "task_id": getattr(task, 'id', None)}

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

@@ -1,6 +1,8 @@
from sqlalchemy import Column, String
from sqlalchemy.orm import relationship, mapped_column, Mapped
from fastapi_users.db import SQLAlchemyBaseUserTableUUID, SQLAlchemyBaseOAuthAccountTableUUID
from sqlalchemy.sql.sqltypes import JSON
from app.core.base import Base
@@ -13,6 +15,7 @@ class User(SQLAlchemyBaseUserTableUUID, Base):
first_name = Column(String(length=100), nullable=True)
last_name = Column(String(length=100), nullable=True)
oauth_accounts = relationship("OAuthAccount", lazy="joined")
config = Column(JSON, default={})
# Relationship
transactions = relationship("Transaction", back_populates="user")

View File

@@ -0,0 +1,33 @@
import os
from os.path import dirname, join
from typing import Optional, Any
import httpx
from httpx_oauth.exceptions import GetProfileError
from httpx_oauth.oauth2 import BaseOAuth2
import app.services.db
BASE_DIR = dirname(__file__)
certs = (
join(BASE_DIR, "public_key.pem"),
join(BASE_DIR, "private_key.key")
)
class CSASOAuth(BaseOAuth2):
def __init__(self, client_id: str, client_secret: str):
super().__init__(
client_id,
client_secret,
base_scopes=["aisp"],
authorize_endpoint="https://webapi.developers.erstegroup.com/api/csas/sandbox/v1/sandbox-idp/auth",
access_token_endpoint="https://webapi.developers.erstegroup.com/api/csas/sandbox/v1/sandbox-idp/token",
refresh_token_endpoint="https://webapi.developers.erstegroup.com/api/csas/sandbox/v1/sandbox-idp/token"
)

View File

@@ -11,7 +11,7 @@ class MojeIDOAuth(CustomOpenID):
super().__init__(
client_id,
client_secret,
"https://mojeid.regtest.nic.cz/.well-known/openid-configuration/",
"https://mojeid.cz/.well-known/openid-configuration/",
"MojeID",
base_scopes=["openid", "email", "profile"],
)

View File

@@ -0,0 +1,28 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDcr/oxgV074ETd
DkP/0l8LFnRofru+m2wNNG/ttVCioTqwnvR4oYxwq3U9qIBsT0D+Rx/Ef7qcpzqf
/w9xt6Hosdv6I5jMHGaVQqLiPuV26/a7WvcmU+PpYuEBmbBHjGVJRBwgPtlUW1VL
M8Pht9YiaagEKvFa6SUidZLfPv+ECohqgH4mgMrEcG/BTnry0/5xQdadRC9o25cl
NtZIesS5GPeelhggFTkbh/FaxvMXhIAaRXT61cnxgxtfM71h5ObX5Lwle9z5a+Tw
xgQhSQq1jbHALYvTwsc4Q/NQGXpGNWy599sb7dg5AkPFSSF4ceXBo/2jOaZCqWrt
FVONZ+blAgMBAAECggEBAJwQbrRXsaFIRiq1jez5znC+3m+PQCHZM55a+NR3pqB7
uE9y+ZvdUr3S4sRJxxfRLDsl/Rcu5L8nm9PNwhQ/MmamcNQCHGoro3fmed3ZcNia
og94ktMt/DztygUhtIHEjVQ0sFc1WufG9xiJcPrM0MfhRAo+fBQ4UCSAVO8/U98B
a4yukrPNeEA03hyjLB9W41pNQfyOtAHqzwDg9Q5XVaGMCLZT1bjCIquUcht5iMva
tiw3cwdiYIklLTzTCsPPK9A/AlWZyUXL8KxtN0mU0kkwlXqASoXZ2nqdkhjRye/V
3JXOmlDtDaJCqWDpH2gHLxMCl7OjfPvuD66bAT3H63kCgYEA5zxW/l6oI3gwYW7+
j6rEjA2n8LikVnyW2e/PZ7pxBH3iBFe2DHx/imeqd/0IzixcM1zZT/V+PTFPQizG
lOU7stN6Zg/LuRdxneHPyLWCimJP7BBJCWyJkuxKy9psokyBhGSLR/phL3fP7UkB
o2I3vGmTFu5A0FzXcNH/cXPMdy8CgYEA9FJw3kyzXlInhJ6Cd63mckLPLYDArUsm
THBoeH2CVTBS5g0bCbl7N1ZxUoYwZPD4lg5V0nWhZALGf+85ULSjX03PMf1cc6WW
EIbZIo9hX+mGRa/FudDd+TlbtBnn0jucwABuLQi9mIepE55Hu9tw5/FT3cHeZVQc
cC0T6ulVvisCgYBCzFeFG+sOdAXl356B+h7VJozBKVWv9kXNp00O9fj4BzVnc78P
VFezr8a66snEZWQtIkFUq+JP4xK2VyD2mlHoktbk7OM5EOCtbzILFQQk3cmgtAOl
SUlkvAXPZcXEDL3NdQ4XOOkiQUY7kb97Z0AamZT4JtNqXaeO29si9wS12QKBgHYg
Hd3864Qg6GZgVOgUNiTsVErFw2KFwQCYIIqQ9CDH+myrzXTILuC0dJnXszI6p5W1
XJ0irmMyTFKykN2KWKrNbe3Xd4mad5GKARWKiSPcPkUXFNwgNhI3PzU2iTTGCaVz
D9HKNhC3FnIbxsb29AHQViITh7kqD43U3ZpoMkJ9AoGAZ+sg+CPfuo3ZMpbcdb3B
ZX2UhAvNKxgHvNnHOjO+pvaM7HiH+BT0650brfBWQ0nTG1dt18mCevVk1UM/5hO9
AtZw06vCLOJ3p3qpgkSlRZ1H7VokG9M8Od0zXqtJrmeLeBq7dfuDisYOuA+NUEbJ
UM/UHByieS6ywetruz0LpM0=
-----END RSA PRIVATE KEY-----

View File

@@ -0,0 +1,31 @@
-----BEGIN CERTIFICATE-----
MIIFSTCCAzGgAwIBAgIEAQIDBDANBgkqhkiG9w0BAQsFADCBgDELMAkGA1UEBhMC
Q1oxDjAMBgNVBAcTBUN6ZWNoMRMwEQYDVQQKEwpFcnN0ZUdyb3VwMRUwEwYDVQQL
EwxFcnN0ZUh1YlRlYW0xETAPBgNVBAMTCEVyc3RlSHViMSIwIAYJKoZIhvcNAQkB
FhNpbmZvQGVyc3RlZ3JvdXAuY29tMB4XDTIyMTIxNDA4MDc1N1oXDTI2MDMxNDA4
MDc1N1owUjEaMBgGA1UEYRMRUFNEQ1otQ05CLTEyMzQ1NjcxCzAJBgNVBAYTAkNa
MRYwFAYDVQQDEw1UUFAgVGVzdCBRV0FDMQ8wDQYDVQQKEwZNeSBUUFAwggEiMA0G
CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDcr/oxgV074ETdDkP/0l8LFnRofru+
m2wNNG/ttVCioTqwnvR4oYxwq3U9qIBsT0D+Rx/Ef7qcpzqf/w9xt6Hosdv6I5jM
HGaVQqLiPuV26/a7WvcmU+PpYuEBmbBHjGVJRBwgPtlUW1VLM8Pht9YiaagEKvFa
6SUidZLfPv+ECohqgH4mgMrEcG/BTnry0/5xQdadRC9o25clNtZIesS5GPeelhgg
FTkbh/FaxvMXhIAaRXT61cnxgxtfM71h5ObX5Lwle9z5a+TwxgQhSQq1jbHALYvT
wsc4Q/NQGXpGNWy599sb7dg5AkPFSSF4ceXBo/2jOaZCqWrtFVONZ+blAgMBAAGj
gfcwgfQwCwYDVR0PBAQDAgHGMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD
AjCBrwYIKwYBBQUHAQMEgaIwgZ8wCAYGBACORgEBMAsGBgQAjkYBAwIBFDAIBgYE
AI5GAQQwEwYGBACORgEGMAkGBwQAjkYBBgMwZwYGBACBmCcCMF0wTDARBgcEAIGY
JwEBDAZQU1BfQVMwEQYHBACBmCcBAgwGUFNQX1BJMBEGBwQAgZgnAQMMBlBTUF9B
STARBgcEAIGYJwEEDAZQU1BfSUMMBUVyc3RlDAZBVC1FUlMwFAYDVR0RBA0wC4IJ
bXl0cHAuY29tMA0GCSqGSIb3DQEBCwUAA4ICAQBlTMPSwz46GMRBEPcy+25gV7xE
5aFS5N6sf3YQyFelRJgPxxPxTHo55WelcK4XmXRQKeQ4VoKf4FgP0Cj74+p0N0gw
wFJDWPGXH3SdjAXPRtG+FOiHwUSoyrmvbL4kk6Vbrd4cF+qe0BlzHzJ2Q6vFLwsk
NYvWzkY9YjoItB38nAnQhyYgl1yHUK/uDWyrwHVfZn1AeTws/hr/KufORuiQfaTU
kvAH1nzi7WSJ6AIQCd2exUEPx/O14Y+oCoJhTVd+RpA/9lkcqebceBijj47b2bvv
QbjymvyTXqHd3L224Y7zVmh95g+CaJ8PRpApdrImfjfDDRy8PaFWx2pd/v0UQgrQ
lgbO6jE7ah/tS0T5q5JtwnLAiOOqHPaKRvo5WB65jcZ2fvOH/0/oZ89noxp1Ihus
vvsjqc9k2h9Rvt2pEjVU40HtQZ6XCmWqgFwK3n9CHrKNV/GqgANIZRNcvXKMCUoB
VoJORVwi2DF4caKSFmyEWuK+5FyCEILtQ60SY/NHVGsUeOuN7OTjZjECARO6p4hz
Uw+GCIXrzmIjS6ydh/LRef+NK28+xTbjmLHu/wnHg9rrHEnTPd39is+byfS7eeLV
Dld/0Xrv88C0wxz63dcwAceiahjyz2mbQm765tOf9rK7EqsvT5M8EXFJ3dP4zwqS
6mNFoIa0XGbAUT3E1w==
-----END CERTIFICATE-----

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

@@ -0,0 +1,124 @@
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__)
OAUTH_DIR = join(dirname(__file__), "..", "oauth")
CERTS = (
join(OAUTH_DIR, "public_key.pem"),
join(OAUTH_DIR, "private_key.key"),
)
async def aload_ceska_sporitelna_transactions(user_id: str) -> None:
try:
uid = UUID(str(user_id))
except Exception:
logger.error("Invalid user_id provided to bank_scraper (async): %r", user_id)
return
await _aload_ceska_sporitelna_transactions(uid)
async def aload_all_ceska_sporitelna_transactions() -> None:
async with async_session_maker() as session:
result = await session.execute(select(User))
users = result.unique().scalars().all()
logger.info("[BankScraper] Starting CSAS scrape for all users | count=%d", len(users))
processed = 0
for user in users:
try:
await _aload_ceska_sporitelna_transactions(user.id)
processed += 1
except Exception:
logger.exception("[BankScraper] Error scraping for user id=%s email=%s", user.id,
getattr(user, 'email', None))
logger.info("[BankScraper] Finished CSAS scrape for all users | processed=%d", processed)
async def _aload_ceska_sporitelna_transactions(user_id: UUID) -> None:
async with (async_session_maker() as session):
result = await session.execute(select(User).where(User.id == user_id))
user: User = result.unique().scalar_one_or_none()
if user is None:
logger.warning("User not found for id=%s", user_id)
return
cfg = user.config or {}
if "csas" not in cfg:
return
cfg = json.loads(cfg["csas"])
if "access_token" not in cfg:
return
accounts = []
try:
async with httpx.AsyncClient(cert=CERTS, timeout=httpx.Timeout(20.0)) as client:
response = await client.get(
"https://webapi.developers.erstegroup.com/api/csas/sandbox/v4/account-information/my/accounts?size=10&page=0&sort=iban&order=desc",
headers={
"Authorization": f"Bearer {cfg['access_token']}",
"WEB-API-key": "09fdc637-3c57-4242-95f2-c2205a2438f3",
"user-involved": "false",
},
)
if response.status_code != httpx.codes.OK:
return
for account in response.json()["accounts"]:
accounts.append(account)
except (httpx.HTTPError,) as e:
logger.exception("[BankScraper] HTTP error during CSAS request | user_id=%s", user_id)
return
for account in accounts:
id = account["id"]
url = f"https://webapi.developers.erstegroup.com/api/csas/sandbox/v4/account-information/my/accounts/{id}/transactions?size=100&page=0&sort=bookingdate&order=desc"
async with httpx.AsyncClient(cert=CERTS) as client:
response = await client.get(
url,
headers={
"Authorization": f"Bearer {cfg['access_token']}",
"WEB-API-key": "09fdc637-3c57-4242-95f2-c2205a2438f3",
"user-involved": "false",
},
)
if response.status_code != httpx.codes.OK:
continue
transactions = response.json()["transactions"]
for transaction in transactions:
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

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

View File

@@ -14,6 +14,7 @@ from httpx_oauth.oauth2 import BaseOAuth2
from app.models.user import User
from app.oauth.bank_id import BankID
from app.oauth.csas import CSASOAuth
from app.oauth.custom_openid import CustomOpenID
from app.oauth.moje_id import MojeIDOAuth
from app.services.db import get_user_db
@@ -32,7 +33,7 @@ providers = {
"BankID": BankID(
os.getenv("BANKID_CLIENT_ID", "CHANGE_ME_CLIENT_ID"),
os.getenv("BANKID_CLIENT_SECRET", "CHANGE_ME_CLIENT_SECRET"),
)
),
}
@@ -101,7 +102,7 @@ bearer_transport = BearerTransport(tokenUrl="auth/jwt/login")
def get_jwt_strategy() -> JWTStrategy:
return JWTStrategy(secret=SECRET, lifetime_seconds=3600)
return JWTStrategy(secret=SECRET, lifetime_seconds=604800)
auth_backend = AuthenticationBackend(

View File

@@ -1,7 +1,13 @@
import logging
import asyncio
import os
import smtplib
from email.message import EmailMessage
from celery import shared_task
import app.services.bank_scraper
logger = logging.getLogger("celery_tasks")
if not logger.handlers:
_h = logging.StreamHandler()
@@ -9,11 +15,133 @@ if not logger.handlers:
logger.setLevel(logging.INFO)
def run_coro(coro) -> None:
"""Run an async coroutine in a fresh event loop without using run_until_complete.
Primary strategy runs in a new loop in the current thread. If that fails due to
debugger patches (e.g., Bad file descriptor from pydevd_nest_asyncio), fall back
to running in a dedicated thread with its own event loop.
"""
import threading
def _cleanup_loop(loop):
try:
pending = [t for t in asyncio.all_tasks(loop) if not t.done()]
for t in pending:
t.cancel()
if pending:
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
except Exception:
pass
finally:
try:
loop.close()
finally:
asyncio.set_event_loop(None)
# First attempt: Run in current thread with a fresh event loop
try:
loop = asyncio.get_event_loop_policy().new_event_loop()
try:
asyncio.set_event_loop(loop)
task = loop.create_task(coro)
task.add_done_callback(lambda _t: loop.stop())
loop.run_forever()
exc = task.exception()
if exc:
raise exc
return
finally:
_cleanup_loop(loop)
except OSError as e:
logger.warning("run_coro primary strategy failed (%s). Falling back to thread runner.", e)
except Exception:
# For any other unexpected errors, try thread fallback as well
logger.exception("run_coro primary strategy raised; attempting thread fallback")
# Fallback: Run in a dedicated thread with its own event loop
error = {"exc": None}
def _thread_target():
loop = asyncio.new_event_loop()
try:
asyncio.set_event_loop(loop)
task = loop.create_task(coro)
task.add_done_callback(lambda _t: loop.stop())
loop.run_forever()
exc = task.exception()
if exc:
error["exc"] = exc
finally:
_cleanup_loop(loop)
th = threading.Thread(target=_thread_target, name="celery-async-runner", daemon=True)
th.start()
th.join()
if error["exc"] is not None:
raise error["exc"]
@shared_task(name="workers.send_email")
def send_email(to: str, subject: str, body: str) -> None:
if not (to and subject and body):
logger.error("Email task missing fields. to=%r subject=%r body_len=%r", to, subject, len(body) if body else 0)
return
# Placeholder for real email sending logic
host = os.getenv("SMTP_HOST")
if not host:
logger.error("SMTP_HOST is not configured; cannot send email")
return
# Configuration
port = int(os.getenv("SMTP_PORT", "25"))
username = os.getenv("SMTP_USERNAME")
password = os.getenv("SMTP_PASSWORD")
use_tls = os.getenv("SMTP_USE_TLS", "0").lower() in {"1", "true", "yes"}
use_ssl = os.getenv("SMTP_USE_SSL", "0").lower() in {"1", "true", "yes"}
timeout = int(os.getenv("SMTP_TIMEOUT", "10"))
mail_from = os.getenv("SMTP_FROM") or username or "noreply@localhost"
# Build message
msg = EmailMessage()
msg["To"] = to
msg["From"] = mail_from
msg["Subject"] = subject
msg.set_content(body)
try:
if use_ssl:
with smtplib.SMTP_SSL(host=host, port=port, timeout=timeout) as smtp:
if username and password:
smtp.login(username, password)
smtp.send_message(msg)
else:
with smtplib.SMTP(host=host, port=port, timeout=timeout) as smtp:
# STARTTLS if requested
if use_tls:
smtp.starttls()
if username and password:
smtp.login(username, password)
smtp.send_message(msg)
logger.info("[Celery] Email sent | to=%s | subject=%s | body_len=%d", to, subject, len(body))
except Exception:
logger.exception("Failed to send email via SMTP to=%s subject=%s host=%s port=%s tls=%s ssl=%s", to, subject,
host, port, use_tls, use_ssl)
@shared_task(name="workers.load_transactions")
def load_transactions(user_id: str) -> None:
if not user_id:
logger.error("Load transactions task missing user_id.")
return
run_coro(app.services.bank_scraper.aload_ceska_sporitelna_transactions(user_id))
# Placeholder for real transaction loading logic
logger.info("[Celery] Transactions loaded for user_id=%s", user_id)
@shared_task(name="workers.load_all_transactions")
def load_all_transactions() -> None:
logger.info("[Celery] Starting load_all_transactions")
run_coro(app.services.bank_scraper.aload_all_ceska_sporitelna_transactions())
logger.info("[Celery] Finished load_all_transactions")

View File

@@ -0,0 +1,5 @@
[tool.pytest.ini_options]
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

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

View File

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

@@ -0,0 +1,55 @@
import types
import asyncio
import pytest
from app.services import user_service
def test_get_oauth_provider_known_unknown():
# Known providers should return a provider instance
bankid = user_service.get_oauth_provider("BankID")
mojeid = user_service.get_oauth_provider("MojeID")
assert bankid is not None
assert mojeid is not None
# Unknown should return None
assert user_service.get_oauth_provider("DoesNotExist") is None
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 604800
assert getattr(strategy, "lifetime_seconds", None) in (604800,)
@pytest.mark.asyncio
async def test_on_after_request_verify_enqueues_email(monkeypatch):
calls = {}
def fake_enqueue_email(to: str, subject: str, body: str):
calls.setdefault("emails", []).append({
"to": to,
"subject": subject,
"body": body,
})
# Patch the enqueue_email used inside user_service
monkeypatch.setattr(user_service, "enqueue_email", fake_enqueue_email)
class DummyUser:
def __init__(self, email):
self.email = email
mgr = user_service.UserManager(user_db=None) # user_db not needed for this method
user = DummyUser("test@example.com")
# Call the hook
await mgr.on_after_request_verify(user, token="abc123", request=None)
# Verify one email has been enqueued with expected content
assert len(calls.get("emails", [])) == 1
email = calls["emails"][0]
assert email["to"] == "test@example.com"
assert "ověření účtu" in email["subject"].lower()
assert "abc123" in email["body"]

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 }}
@@ -78,6 +80,16 @@ spec:
secretKeyRef:
name: prod
key: BANKID_CLIENT_SECRET
- name: CSAS_CLIENT_ID
valueFrom:
secretKeyRef:
name: prod
key: CSAS_CLIENT_ID
- name: CSAS_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: prod
key: CSAS_CLIENT_SECRET
- name: DOMAIN
value: {{ required "Set .Values.domain" .Values.domain | quote }}
- name: DOMAIN_SCHEME
@@ -91,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,25 @@
{{- if .Values.cron.enabled }}
apiVersion: batch/v1
kind: CronJob
metadata:
name: cronjob
spec:
schedule: {{ .Values.cron.schedule | quote }}
concurrencyPolicy: {{ .Values.cron.concurrencyPolicy | quote }}
jobTemplate:
spec:
template:
spec:
containers:
- name: cronjob
image: curlimages/curl:latest
imagePullPolicy: IfNotPresent
args:
- -sS
- -o
- /dev/null
- -w
- "%{http_code}"
- {{ printf "%s://%s.%s.svc.cluster.local%s" .Values.cron.scheme .Values.app.name .Release.Namespace .Values.cron.endpoint | quote }}
restartPolicy: OnFailure
{{- end }}

View File

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

@@ -8,6 +8,8 @@ stringData:
MOJEID_CLIENT_SECRET: {{ .Values.oauth.mojeid.clientSecret | quote }}
BANKID_CLIENT_ID: {{ .Values.oauth.bankid.clientId | quote }}
BANKID_CLIENT_SECRET: {{ .Values.oauth.bankid.clientSecret | quote }}
CSAS_CLIENT_ID: {{ .Values.oauth.csas.clientId | quote }}
CSAS_CLIENT_SECRET: {{ .Values.oauth.csas.clientSecret | quote }}
# Database credentials
MARIADB_DB: {{ required "Set .Values.deployment" .Values.deployment | quote }}
MARIADB_USER: {{ required "Set .Values.deployment" .Values.deployment | quote }}
@@ -16,3 +18,11 @@ 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 }}
SMTP_HOST: {{ .Values.smtp.host | default "" | quote }}
SMTP_PORT: {{ .Values.smtp.port | default 587 | quote }}
SMTP_USERNAME: {{ .Values.smtp.username | default "" | quote }}
SMTP_PASSWORD: {{ .Values.smtp.password | default "" | quote }}
SMTP_USE_TLS: {{ .Values.smtp.tls | default false | quote }}
SMTP_USE_SSL: {{ .Values.smtp.ssl | default false | quote }}
SMTP_FROM: {{ .Values.smtp.from | default "" | quote }}

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
@@ -70,3 +70,53 @@ spec:
secretKeyRef:
name: prod
key: SENTRY_DSN
- name: CSAS_CLIENT_ID
valueFrom:
secretKeyRef:
name: prod
key: CSAS_CLIENT_ID
- name: CSAS_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: prod
key: CSAS_CLIENT_SECRET
- name: DB_ENCRYPTION_KEY
valueFrom:
secretKeyRef:
name: prod
key: DB_ENCRYPTION_KEY
- name: SMTP_HOST
valueFrom:
secretKeyRef:
name: prod
key: SMTP_HOST
- name: SMTP_PORT
valueFrom:
secretKeyRef:
name: prod
key: SMTP_PORT
- name: SMTP_USERNAME
valueFrom:
secretKeyRef:
name: prod
key: SMTP_USERNAME
- name: SMTP_PASSWORD
valueFrom:
secretKeyRef:
name: prod
key: SMTP_PASSWORD
- name: SMTP_USE_TLS
valueFrom:
secretKeyRef:
name: prod
key: SMTP_USE_TLS
- name: SMTP_USE_SSL
valueFrom:
secretKeyRef:
name: prod
key: SMTP_USE_SSL
- name: SMTP_FROM
valueFrom:
secretKeyRef:
name: prod
key: SMTP_FROM

View File

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

View File

@@ -35,6 +35,23 @@ worker:
# Queue name for Celery worker and for CRD Queue
mailQueueName: "mail_queue"
cron:
enabled: false
schedule: "*/5 * * * *" # every 5 minutes
scheme: "http"
endpoint: "/_cron"
concurrencyPolicy: "Forbid"
smtp:
host:
port: 587
username: ""
password: ""
tls: false
ssl: false
from: ""
service:
port: 80
@@ -46,6 +63,9 @@ oauth:
mojeid:
clientId: ""
clientSecret: ""
csas:
clientId: ""
clientSecret: ""
rabbitmq:
create: true
@@ -72,3 +92,4 @@ database:
userName: app-demo-user
secretName: app-demo-database-secret
password: ""
encryptionSecret: ""

View File

@@ -8,63 +8,63 @@ 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 | |
| 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 | |
| | 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 | |
| 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 | |
| | Optimized Dockerfile | 5 | |
| | Docker Compose | 5 | |
| | Persistent storage | 5 | |
| Deployment & Scaling | Manual deployment | 5 | |
| | Automated deployment | 5 | |
| | Multiple replicas | 5 | |
| | Kubernetes deployment | 10 | |
| 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 | |
| | 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 | |
| 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 | |
| Technical Excellence | Real-time features | 10 | |
| | Creative problem solving | 10 | |
| | Performance optimization | 5 | |
| | Exceptional user experience | 5 | |
| **Total** | | **255** | **[Your Total]** |
| 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,23 +1,55 @@
import { useEffect, useState } from 'react';
import './App.css';
import LoginRegisterPage from './pages/LoginRegisterPage';
import Dashboard from './pages/Dashboard';
import { logout } from './api';
import { BACKEND_URL } from './config';
function App() {
const [hasToken, setHasToken] = useState<boolean>(!!localStorage.getItem('token'));
const [processingCallback, setProcessingCallback] = useState<boolean>(false);
useEffect(() => {
// Handle OAuth callback: /oauth-callback?access_token=...&token_type=...
if (window.location.pathname === '/oauth-callback') {
const params = new URLSearchParams(window.location.search);
const token = params.get('access_token');
if (token) {
localStorage.setItem('token', token);
const path = window.location.pathname;
// Minimal handling for provider callbacks: /auth|/oauth/:provider/callback?code=...&state=...
const parts = path.split('/').filter(Boolean);
const isCallback = parts.length === 3 && (parts[0] === 'auth') && parts[2] === 'callback';
if (isCallback) {
// Guard against double invocation in React 18 StrictMode/dev
const w = window as any;
if (w.__oauthCallbackHandled) {
return;
}
w.__oauthCallbackHandled = true;
setProcessingCallback(true);
const provider = parts[1];
const qs = window.location.search || '';
const base = BACKEND_URL.replace(/\/$/, '');
const url = `${base}/auth/${encodeURIComponent(provider)}/callback${qs}`;
(async () => {
try {
const token = localStorage.getItem('token');
const res = await fetch(url, {
method: 'GET',
credentials: 'include',
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
let data: any = null;
try {
data = await res.json();
} catch {}
if (provider !== 'csas' && res.ok && data?.access_token) {
localStorage.setItem('token', data?.access_token);
setHasToken(true);
}
// Clean URL and redirect to home
} catch {}
// Clean URL and go home regardless of result
setProcessingCallback(false);
window.history.replaceState({}, '', '/');
})();
}
const onStorage = (e: StorageEvent) => {
@@ -27,6 +59,24 @@ function App() {
return () => window.removeEventListener('storage', onStorage);
}, []);
if (processingCallback) {
return (
<div style={{ display: 'grid', placeItems: 'center', height: '100vh' }}>
<div className="card" style={{ width: 360, textAlign: 'center', padding: 24 }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12 }}>
<svg width="48" height="48" viewBox="0 0 50 50" aria-label="Loading">
<circle cx="25" cy="25" r="20" fill="none" stroke="#3b82f6" strokeWidth="5" strokeLinecap="round" strokeDasharray="31.4 31.4">
<animateTransform attributeName="transform" type="rotate" from="0 25 25" to="360 25 25" dur="0.9s" repeatCount="indefinite" />
</circle>
</svg>
<div>Finishing sign-in</div>
<div className="muted">Please wait</div>
</div>
</div>
</div>
);
}
if (!hasToken) {
return <LoginRegisterPage onLoggedIn={() => setHasToken(true)} />;
}

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,23 +1,183 @@
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 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);
// New transaction form state
const [amount, setAmount] = useState<string>('');
const [description, setDescription] = useState('');
const [selectedCategoryId, setSelectedCategoryId] = useState<number | ''>('');
// Start CSAS (George) OAuth after login
async function startOauthCsas() {
const base = BACKEND_URL.replace(/\/$/, '');
const url = `${base}/auth/csas/authorize`;
try {
const token = localStorage.getItem('token');
const res = await fetch(url, {
credentials: 'include',
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
const data = await res.json();
if (data && typeof data.authorization_url === 'string') {
window.location.assign(data.authorization_url);
} else {
alert('Cannot start CSAS OAuth.');
}
} catch (e) {
alert('Cannot start CSAS OAuth.');
}
}
// Filters
const [minAmount, setMinAmount] = useState<string>('');
@@ -25,13 +185,45 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
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 {
@@ -39,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);
@@ -55,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">
<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>
@@ -97,22 +384,25 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
<main className="page space-y">
{current === 'home' && (
<>
<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 className="card space-y">
<h3>Bank connections</h3>
<div className="connection-row">
<p className="muted" style={{ margin: 0 }}>Connect your CSAS (George) account.</p>
<button className="btn primary" onClick={startOauthCsas}>Connect CSAS (George)</button>
</div>
<div className="connection-row">
<p className="muted" style={{ margin: 0 }}>Generate data from a mock bank.</p>
<button className="btn primary" onClick={() => setMockModalOpen(true)}>Connect Mock Bank</button>
</div>
</section>
<section className="card">
<h3>Filters</h3>
<div className="form-row">
<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) : '')}>
@@ -124,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 ? (
@@ -132,26 +445,116 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
) : filtered.length === 0 ? (
<div>No transactions</div>
) : (
<table className="table">
<>
<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>ID</th>
<th>Date</th>
<th style={{ textAlign: 'right' }}>Amount</th>
<th>Description</th>
<th>Categories</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{filtered.map(t => (
{visible.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>
{/* 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>
</>
@@ -162,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

@@ -2,10 +2,21 @@ import { useState, useEffect } from 'react';
import { login, register } from '../api';
import { BACKEND_URL } from '../config';
function oauthUrl(provider: 'mojeid' | 'bankid') {
// Minimal helper to start OAuth: fetch authorization_url and redirect
async function startOauth(provider: 'mojeid' | 'bankid') {
const base = BACKEND_URL.replace(/\/$/, '');
const redirect = encodeURIComponent(window.location.origin + '/oauth-callback');
return `${base}/auth/${provider}/authorize?redirect_url=${redirect}`;
const url = `${base}/auth/${provider}/authorize`;
try {
const res = await fetch(url, { credentials: 'include' });
const data = await res.json();
if (data && typeof data.authorization_url === 'string') {
window.location.assign(data.authorization_url);
} else {
alert('Cannot start OAuth.');
}
} catch (e) {
alert('Cannot start OAuth.');
}
}
export default function LoginRegisterPage({ onLoggedIn }: { onLoggedIn: () => void }) {
@@ -69,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)} />
@@ -84,8 +95,8 @@ export default function LoginRegisterPage({ onLoggedIn }: { onLoggedIn: () => vo
<div className="actions" style={{ justifyContent: 'space-between' }}>
<div className="muted">Or continue with</div>
<div className="actions">
<a className="btn" href={oauthUrl('mojeid')}>MojeID</a>
<a className="btn" href={oauthUrl('bankid')}>BankID</a>
<button type="button" className="btn" onClick={() => startOauth('mojeid')}>MojeID</button>
<button type="button" className="btn" onClick={() => startOauth('bankid')}>BankID</button>
<button className="btn primary" type="submit" disabled={loading}>{loading ? 'Please wait…' : (mode === 'login' ? 'Login' : 'Register')}</button>
</div>
</div>

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

@@ -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.
- [x] Change the name on frontend from 7project
- [x] Finalize the funcionality and everyting in the code part
- [ ] Try to finalize report with focus on reproducibility
- [ ] More high level explanation of the workflow in the report
---

View File

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

View File

@@ -34,9 +34,16 @@ flowchart LR
client[Client/Frontend] <--> svc[Backend API]
svc --> proc_queue
svc <--> db[(Database)]
svc <--> cache[(Cache)]
```
The workflow works in the following way:
- Client connects to the frontend. After login, frontend automatically fetches the stored transactions from
the database via the backend API
- When the client opts for fetching new transactions via the Bank API, the backend delegates the task
to a background worker service via the Message queue.
- After successful load, these transactions are stored to the database and displayed to the client
- There is also a Task planner, that executes periodic tasks, like fetching new transactions automatically from the Bank API
### Components
- Frontend (frontend/): React + TypeScript app built with Vite. Talks to the backend via REST, handles login/registration, shows latest transactions, filtering, and allows adding transactions.
@@ -52,46 +59,45 @@ flowchart LR
- Backend: Python, FastAPI, FastAPI Users, SQLAlchemy, Pydantic, Alembic, Celery
- 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
Backend: FastAPI, fastapi-users, SQLAlchemy, pydantic v2, Alembic, Celery, uvicorn
Frontend: React, TypeScript, Vite
Services: PostgreSQL, RabbitMQ, Redis
## Build Instructions
## Local development
You can run the project with Docker Compose (recommended for local development) or run services manually.
You can run the project with Docker Compose and Python virtual environment for testing and dev purposes
### 1) Clone the Repository
@@ -103,9 +109,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,24 +125,25 @@ 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
export DATABASE_URL=postgresql+asyncpg://user:password@127.0.0.1:5432/app
export RABBITMQ_URL=amqp://guest:guest@127.0.0.1:5672/
export REDIS_URL=redis://127.0.0.1:6379/0
export FRONTEND_DOMAIN_SCHEME=http://localhost:5173
export BANKID_CLIENT_ID=CHANGE_ME
export BANKID_CLIENT_SECRET=CHANGE_ME
export CSAS_CLIENT_ID=CHANGE_ME
export CSAS_CLIENT_SECRET=CHANGE_ME
export MOJEID_CLIENT_ID=CHANGE_ME
export MOJEID_CLIENT_SECRET=CHANGE_ME
# 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,25 +158,60 @@ 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
### Setup Cluster
Deployment should work on any Kubernetes cluster. However, we are using 4 TalosOS virtual machines (1 control plane, 3 workers)
running on top of Proxmox VE.
### Local (Docker Compose)
1) Create 4 VMs with TalosOS
2) Install talosctl for your OS: https://docs.siderolabs.com/talos/v1.10/getting-started/talosctl
3) Generate Talos config
```bash
# TODO: add commands
```
4) Edit the generated worker.yaml
- add google container registry mirror
- add modules from config generator
- add extramounts for persistent storage
- add kernel modules
Described in the previous section (Manual Local Run)
5) Apply the config to the VMs
```bash
#TODO: add config apply commands
```
### Kubernetes (via OpenTofu + Helm)
6) Verify the cluster is up
```bash
```
1) Provision platform services (RabbitMQ/Redis/ingress/tunnel/etc.) with OpenTofu
7) Export kubeconfig
```bash
# TODO: add export command
```
### Install
1) Install base services to cluster
```bash
cd tofu
# copy and edit variables
cp terraform.tfvars.example terraform.tfvars
# authenticate to your cluster/cloud as needed, then:
tofu init
tofu plan
tofu apply -exclude modules.cloudflare
tofu apply
```
@@ -215,28 +256,28 @@ open http://localhost:5173
```
## Testing Instructions
The tests are located in 7project/backend/tests directory
If you want to test locally, you have to have the DB running locally as well (start the docker compose in /backend).
```bash
cd backend
```
### Unit Tests
There are only 3 basic unit tests, since our services logic is very simple
```bash
# Commands to run unit tests
# For example:
# go test ./...
# npm test
pytest tests/test_unit_user_service.py
```
### Integration Tests
There are 11 basic unit tests, testing the individual backend API logic
```bash
# Commands to run integration tests
# Any setup required for integration tests
pytest tests/test_integration_app.py
```
### End-to-End Tests
There are 7 e2e tests testing more complex app logic
```bash
# Commands to run e2e tests
# How to set up test environment
pytest tests/test_e2e.py
```
## Usage Examples
@@ -313,23 +354,23 @@ curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8000/authenticated-route
---
## Self-Assessment Table
## Progress Table
> Be honest and detailed in your assessments.
> This information is used for individual grading.
> Link to the specific commit on GitHub for each contribution.
| Task/Component | Assigned To | Status | Time Spent | Difficulty | Notes |
|-----------------------------------------------------------------------|-------------| ------------- |----------------|------------| ----------- |
|-----------------------------------------------------------------------|-------------| ------------- |------------|------------| ----------- |
| [Project Setup & Repository](https://github.com/dat515-2025/Group-8#) | Lukas | ✅ Complete | [X hours] | Medium | [Any notes] |
| [Design Document](https://github.com/dat515-2025/Group-8/blob/main/6design/design.md) | Both | ✅ Complete | 2 Hours | Easy | [Any notes] |
| [Backend API Development](https://github.com/dat515-2025/Group-8/tree/main/7project/backend/app/api) | Dejan | ✅ 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] |
| [Frontend Development](https://github.com/dat515-2025/Group-8/tree/main/7project/frontend) | Dejan | 🔄 In Progress | 7 hours so far | Medium | [Any notes] |
| [Design Document](https://github.com/dat515-2025/Group-8/blob/main/6design/design.md) | Both | ✅ Complete | 4 Hours | Easy | [Any notes] |
| [Backend API Development](https://github.com/dat515-2025/Group-8/tree/main/7project/backend/app/api) | Dejan | ✅ Complete | 12 hours | Medium | [Any notes] |
| [Database Setup & Models](https://github.com/dat515-2025/Group-8/tree/main/7project/backend/app/models) | Lukas | 🔄 In Progress | [X hours] | Medium | [Any notes] |
| [Frontend Development](https://github.com/dat515-2025/Group-8/tree/main/7project/frontend) | Dejan | ✅ Complete | 17 hours | Medium | [Any notes] |
| [Docker Configuration](https://github.com/dat515-2025/Group-8/blob/main/7project/compose.yml) | Lukas | ✅ Complete | [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 | ✅ Complete | 16 hours | Medium | [Any notes] |
| [Documentation](https://github.com/dat515-2025/group-name) | Both | 🔄 In Progress | [X hours] | Easy | [Any notes] |
| [Presentation Video](https://github.com/dat515-2025/group-name) | Both | ❌ Not Started | [X hours] | Medium | [Any notes] |
**Legend**: ✅ Complete | 🔄 In Progress | ⏳ Pending | ❌ Not Started
@@ -338,12 +379,13 @@ 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 |
|----------------|---------------------|------------|----------------------------------------------------|
| 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]** | |
@@ -351,12 +393,17 @@ curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8000/authenticated-route
### 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** | |
|-----------------|----------------------|--------|---------------------------------------------------------------|
| 25.9. | Design | 2 | 6design |
| 9.10 to 11.10. | Backend APIs | 12 | Implemented Backend APIs |
| 13.10 to 15.10. | Frontend Development | 8 | Created user interface mockups |
| Continually | Documentation | 6 | Documenting the dev process |
| 21.10 to 23.10 | Tests, frontend | 10 | Test basics, balance charts, and frontend improvement |
| 28.10 to 30.10 | CI | 6 | Integrated tests with test database setup on github workflows |
| 28.10 to 30.10 | Frontend | 7 | UI improvements and exchange rate API integration |
| 4.11 to 6.11 | Tests | 6 | Test fixes improvement, more integration and e2e |
| 4.11 to 6.11 | Frontend | 6 | Fixes, Improved UI, added support for mobile devices |
| **Total** | | **63** | |
### Group Total: [XXX.X] hours

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