85 Commits

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

View File

@@ -12,25 +12,7 @@ jobs:
test: test:
name: Run Python Tests name: Run Python Tests
if: github.event.action != 'closed' if: github.event.action != 'closed'
runs-on: ubuntu-latest uses: ./.github/workflows/run-tests.yml
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run tests with pytest
run: pytest
working-directory: ./7project/backend
build: build:
if: github.event.action != 'closed' if: github.event.action != 'closed'
@@ -103,6 +85,7 @@ jobs:
DOMAIN_SCHEME: "${{ needs.get_urls.outputs.backend_url_scheme }}" DOMAIN_SCHEME: "${{ needs.get_urls.outputs.backend_url_scheme }}"
FRONTEND_DOMAIN: "${{ needs.get_urls.outputs.frontend_url }}" FRONTEND_DOMAIN: "${{ needs.get_urls.outputs.frontend_url }}"
FRONTEND_DOMAIN_SCHEME: "${{ needs.get_urls.outputs.frontend_url_scheme }}" FRONTEND_DOMAIN_SCHEME: "${{ needs.get_urls.outputs.frontend_url_scheme }}"
UNIRATE_API_KEY: ${{ secrets.UNIRATE_API_KEY }}
run: | run: |
PR=${{ github.event.pull_request.number }} PR=${{ github.event.pull_request.number }}
RELEASE=myapp-pr-$PR RELEASE=myapp-pr-$PR
@@ -119,7 +102,9 @@ jobs:
--set image.digest="$DIGEST" \ --set image.digest="$DIGEST" \
--set-string rabbitmq.password="$RABBITMQ_PASSWORD" \ --set-string rabbitmq.password="$RABBITMQ_PASSWORD" \
--set-string database.password="$DB_PASSWORD" \ --set-string database.password="$DB_PASSWORD" \
--set-string database.encryptionSecret="$PR" --set-string database.encryptionSecret="$PR" \
--set-string app.name="finance-tracker-pr-$PR" \
--set-string unirate.key="$UNIRATE_API_KEY"
- name: Post preview URLs as PR comment - name: Post preview URLs as PR comment
uses: actions/github-script@v7 uses: actions/github-script@v7

View File

@@ -23,26 +23,7 @@ concurrency:
jobs: jobs:
test: test:
name: Run Python Tests name: Run Python Tests
if: github.event.action != 'closed' uses: ./.github/workflows/run-tests.yml
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run tests with pytest
run: pytest
working-directory: ./7project/backend
build: build:
name: Build and push image (reusable) name: Build and push image (reusable)
@@ -111,6 +92,14 @@ jobs:
CSAS_CLIENT_ID: ${{ secrets.CSAS_CLIENT_ID }} CSAS_CLIENT_ID: ${{ secrets.CSAS_CLIENT_ID }}
CSAS_CLIENT_SECRET: ${{ secrets.CSAS_CLIENT_SECRET }} CSAS_CLIENT_SECRET: ${{ secrets.CSAS_CLIENT_SECRET }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SMTP_HOST: ${{ secrets.SMTP_HOST }}
SMTP_PORT: ${{ secrets.SMTP_PORT }}
SMTP_USERNAME: ${{ secrets.SMTP_USERNAME }}
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
SMTP_USE_TLS: ${{ secrets.SMTP_USE_TLS }}
SMTP_USE_SSL: ${{ secrets.SMTP_USE_SSL }}
SMTP_FROM: ${{ secrets.SMTP_FROM }}
UNIRATE_API_KEY: ${{ secrets.UNIRATE_API_KEY }}
run: | run: |
helm upgrade --install myapp ./7project/charts/myapp-chart \ helm upgrade --install myapp ./7project/charts/myapp-chart \
-n prod --create-namespace \ -n prod --create-namespace \
@@ -130,4 +119,12 @@ jobs:
--set-string oauth.csas.clientId="$CSAS_CLIENT_ID" \ --set-string oauth.csas.clientId="$CSAS_CLIENT_ID" \
--set-string oauth.csas.clientSecret="$CSAS_CLIENT_SECRET" \ --set-string oauth.csas.clientSecret="$CSAS_CLIENT_SECRET" \
--set-string sentry_dsn="$SENTRY_DSN" \ --set-string sentry_dsn="$SENTRY_DSN" \
--set-string database.encryptionSecret="${{ secrets.PROD_DB_ENCRYPTION_KEY }}" --set-string database.encryptionSecret="${{ secrets.PROD_DB_ENCRYPTION_KEY }}" \
--set-string smtp.host="$SMTP_HOST" \
--set smtp.port="$SMTP_PORT" \
--set-string smtp.username="$SMTP_USERNAME" \
--set-string smtp.password="$SMTP_PASSWORD" \
--set-string smtp.tls="$SMTP_USE_TLS" \
--set-string smtp.ssl="$SMTP_USE_SSL" \
--set-string smtp.from="$SMTP_FROM" \
--set-string unirate.key="$UNIRATE_API_KEY"

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,66 @@
import os
from typing import List
import httpx
from fastapi import APIRouter, HTTPException, Query, status
router = APIRouter(prefix="/exchange-rates", tags=["exchange-rates"])
@router.get("", status_code=status.HTTP_200_OK)
async def get_exchange_rates(symbols: str = Query("EUR,USD,NOK", description="Comma-separated currency codes to fetch vs CZK")):
"""
Fetch exchange rates from UniRate API on the backend and return CZK-per-target rates.
- Always requests CZK in addition to requested symbols to compute conversion from USD-base.
- Returns a list of {currencyCode, rate} where rate is CZK per 1 unit of the target currency.
"""
api_key = os.getenv("UNIRATE_API_KEY")
if not api_key:
raise HTTPException(status_code=500, detail="Server is not configured with UNIRATE_API_KEY")
# Ensure CZK is included for conversion
requested = [s.strip().upper() for s in symbols.split(",") if s.strip()]
if "CZK" not in requested:
requested.append("CZK")
query_symbols = ",".join(sorted(set(requested)))
url = f"https://unirateapi.com/api/rates?api_key={api_key}&symbols={query_symbols}"
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(15.0)) as client:
resp = await client.get(url)
if resp.status_code != httpx.codes.OK:
raise HTTPException(status_code=502, detail=f"Upstream UniRate error: HTTP {resp.status_code}")
data = resp.json()
except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"Failed to contact UniRate: {str(e)}")
# Validate response structure
rates = data.get("rates") if isinstance(data, dict) else None
base = data.get("base") if isinstance(data, dict) else None
if not rates or base != "USD" or "CZK" not in rates:
# Prefer upstream message when available
detail = data.get("message") if isinstance(data, dict) else None
if not detail and isinstance(data, dict):
err = data.get("error")
if isinstance(err, dict):
detail = err.get("info")
raise HTTPException(status_code=502, detail=detail or "Invalid response from UniRate API")
czk_per_usd = rates["CZK"]
# Build result excluding CZK itself
result = []
for code in requested:
if code == "CZK":
continue
target_per_usd = rates.get(code)
if target_per_usd in (None, 0):
# Skip unavailable or invalid
continue
czk_per_target = czk_per_usd / target_per_usd
result.append({"currencyCode": code, "rate": czk_per_target})
return result

View File

@@ -0,0 +1,116 @@
from datetime import datetime, timedelta
from typing import List, Optional
import random
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field, conint, confloat, validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.db import get_async_session
from app.services.user_service import current_active_user
from app.models.user import User
from app.models.transaction import Transaction
from app.models.categories import Category
from app.schemas.transaction import TransactionRead
router = APIRouter(prefix="/mock-bank", tags=["mock-bank"])
class GenerateOptions(BaseModel):
count: conint(strict=True, gt=0) = Field(default=10, description="Number of transactions to generate")
minAmount: confloat(strict=True) = Field(default=-200.0, description="Minimum transaction amount")
maxAmount: confloat(strict=True) = Field(default=200.0, description="Maximum transaction amount")
startDate: Optional[str] = Field(None, description="Earliest date (YYYY-MM-DD)")
endDate: Optional[str] = Field(None, description="Latest date (YYYY-MM-DD)")
categoryIds: List[int] = Field(default_factory=list, description="Optional category IDs to assign randomly")
@validator("maxAmount")
def _validate_amounts(cls, v, values):
min_amt = values.get("minAmount")
if min_amt is not None and v < min_amt:
raise ValueError("maxAmount must be greater than or equal to minAmount")
return v
@validator("endDate")
def _validate_dates(cls, v, values):
sd = values.get("startDate")
if v and sd:
try:
ed = datetime.strptime(v, "%Y-%m-%d").date()
st = datetime.strptime(sd, "%Y-%m-%d").date()
except ValueError:
raise ValueError("Invalid date format, expected YYYY-MM-DD")
if ed < st:
raise ValueError("endDate must be greater than or equal to startDate")
return v
class GeneratedTransaction(BaseModel):
amount: float
date: str # YYYY-MM-DD
category_ids: List[int] = []
description: Optional[str] = None
@router.post("/generate", response_model=List[GeneratedTransaction])
async def generate_mock_transactions(
options: GenerateOptions,
user: User = Depends(current_active_user),
):
# Seed randomness per user to make results less erratic across multiple calls in quick succession
seed = int(datetime.utcnow().timestamp()) ^ int(user.id)
rnd = random.Random(seed)
# Determine date range
if options.startDate:
start_date = datetime.strptime(options.startDate, "%Y-%m-%d").date()
else:
start_date = (datetime.utcnow() - timedelta(days=365)).date()
if options.endDate:
end_date = datetime.strptime(options.endDate, "%Y-%m-%d").date()
else:
end_date = datetime.utcnow().date()
span_days = max(0, (end_date - start_date).days)
results: List[GeneratedTransaction] = []
for _ in range(options.count):
amount = round(rnd.uniform(options.minAmount, options.maxAmount), 2)
# Pick a random date in the inclusive range
rand_day = rnd.randint(0, span_days) if span_days > 0 else 0
tx_date = start_date + timedelta(days=rand_day)
# Pick category randomly from provided list, or empty
if options.categoryIds:
cat = [rnd.choice(options.categoryIds)]
else:
cat = []
# Optional simple description for flavor
desc = None
# Assemble
results.append(GeneratedTransaction(
amount=amount,
date=tx_date.isoformat(),
category_ids=cat,
description=desc,
))
return results
@router.get("/scrape")
async def scrape_mock_bank():
# 80% of the time: nothing to scrape
if random.random() < 0.8:
return []
transactions = []
count = random.randint(1, 10)
for _ in range(count):
transactions.append({
"amount": round(random.uniform(-200.0, 200.0), 2),
"date": (datetime.utcnow().date() - timedelta(days=random.randint(0, 30))).isoformat(),
"description": "Mock transaction",
})
return transactions

View File

@@ -1,11 +1,17 @@
import json
import logging import logging
import os import os
import sys
from datetime import datetime from datetime import datetime
from pythonjsonlogger import jsonlogger
from fastapi import Depends, FastAPI from fastapi import Depends, FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from prometheus_fastapi_instrumentator import Instrumentator, metrics
from starlette.requests import Request from starlette.requests import Request
from app.services.prometheus import number_of_users, number_of_transactions
from app.services import bank_scraper from app.services import bank_scraper
from app.workers.celery_tasks import load_transactions, load_all_transactions from app.workers.celery_tasks import load_transactions, load_all_transactions
from app.models.user import User, OAuthAccount from app.models.user import User, OAuthAccount
@@ -15,13 +21,17 @@ from app.api.auth import router as auth_router
from app.api.csas import router as csas_router from app.api.csas import router as csas_router
from app.api.categories import router as categories_router from app.api.categories import router as categories_router
from app.api.transactions import router as transactions_router from app.api.transactions import router as transactions_router
from app.services.user_service import auth_backend, current_active_verified_user, fastapi_users, get_oauth_provider, UserManager, get_jwt_strategy from app.api.exchange_rates import router as exchange_rates_router
from app.services.user_service import auth_backend, current_active_verified_user, fastapi_users, get_oauth_provider, \
UserManager, get_jwt_strategy
from app.core.security import extract_bearer_token, is_token_revoked, decode_and_verify_jwt
from app.services.user_service import SECRET
from fastapi import FastAPI from fastapi import FastAPI
import sentry_sdk import sentry_sdk
from fastapi_users.db import SQLAlchemyUserDatabase from fastapi_users.db import SQLAlchemyUserDatabase
from app.core.db import async_session_maker from app.core.db import async_session_maker, engine
from app.core.base import Base
sentry_sdk.init( sentry_sdk.init(
dsn=os.getenv("SENTRY_DSN"), dsn=os.getenv("SENTRY_DSN"),
@@ -29,7 +39,6 @@ sentry_sdk.init(
) )
fastApi = FastAPI() fastApi = FastAPI()
app = fastApi
# CORS for frontend dev server # CORS for frontend dev server
fastApi.add_middleware( fastApi.add_middleware(
@@ -44,11 +53,59 @@ fastApi.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
if not os.getenv("PYTEST_RUN_CONFIG"):
prometheus = Instrumentator().instrument(fastApi)
# Register custom metrics
prometheus.add(number_of_users()).add(number_of_transactions())
prometheus.expose(
fastApi,
endpoint="/metrics",
include_in_schema=True,
)
fastApi.include_router(auth_router) fastApi.include_router(auth_router)
fastApi.include_router(categories_router) fastApi.include_router(categories_router)
fastApi.include_router(transactions_router) fastApi.include_router(transactions_router)
fastApi.include_router(exchange_rates_router)
from app.api.mock_bank import router as mock_bank_router
fastApi.include_router(mock_bank_router)
for h in list(logging.root.handlers):
logging.root.removeHandler(h)
_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)
logging.basicConfig(filename='app.log', level=logging.INFO, format='%(asctime)s %(message)s')
@fastApi.middleware("http") @fastApi.middleware("http")
async def log_traffic(request: Request, call_next): async def log_traffic(request: Request, call_next):
start_time = datetime.now() start_time = datetime.now()
@@ -66,9 +123,10 @@ async def log_traffic(request: Request, call_next):
"process_time": process_time, "process_time": process_time,
"client_host": client_host "client_host": client_host
} }
logging.info(str(log_params)) logging.getLogger(__name__).info("http_request", extra=log_params)
return response return response
fastApi.include_router( fastApi.include_router(
fastapi_users.get_oauth_router( fastapi_users.get_oauth_router(
get_oauth_provider("MojeID"), get_oauth_provider("MojeID"),
@@ -95,6 +153,7 @@ fastApi.include_router(
fastApi.include_router(csas_router) fastApi.include_router(csas_router)
# Liveness/root endpoint # Liveness/root endpoint
@fastApi.get("/", include_in_schema=False) @fastApi.get("/", include_in_schema=False)
async def root(): async def root():
@@ -106,15 +165,12 @@ async def authenticated_route(user: User = Depends(current_active_verified_user)
return {"message": f"Hello {user.email}!"} return {"message": f"Hello {user.email}!"}
@fastApi.get("/debug/scrape/csas/all", tags=["debug"]) @fastApi.get("/_cron", include_in_schema=False)
async def debug_scrape_csas_all(): async def handle_cron(request: Request):
logging.info("[Debug] Queueing CSAS scrape for all users via HTTP endpoint (Celery)") # 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() task = load_all_transactions.delay()
return {"status": "queued", "action": "csas_scrape_all", "task_id": getattr(task, 'id', None)} return {"status": "queued", "action": "csas_scrape_all", "task_id": getattr(task, 'id', None)}
@fastApi.post("/debug/scrape/csas/{user_id}", tags=["debug"])
async def debug_scrape_csas_user(user_id: str, user: User = Depends(current_active_verified_user)):
logging.info("[Debug] Queueing CSAS scrape for single user via HTTP endpoint (Celery) | user_id=%s", user_id)
task = load_transactions.delay(user_id)
return {"status": "queued", "action": "csas_scrape_single", "user_id": user_id, "task_id": getattr(task, 'id', None)}

View File

@@ -1,5 +1,7 @@
import os import os
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.core.base import Base from app.core.base import Base
DATABASE_URL = os.getenv("DATABASE_URL") DATABASE_URL = os.getenv("DATABASE_URL")
@@ -19,9 +21,11 @@ from app.models.user import User
from app.models.transaction import Transaction from app.models.transaction import Transaction
from app.models.categories import Category from app.models.categories import Category
ssl_enabled = os.getenv("MARIADB_HOST", "localhost") != "localhost" host_env = os.getenv("MARIADB_HOST", "localhost")
ssl_enabled = host_env not in {"localhost", "127.0.0.1"}
connect_args = {"ssl": {"ssl": True}} if ssl_enabled else {} connect_args = {"ssl": {"ssl": True}} if ssl_enabled else {}
# Async engine/session for the async parts of the app
engine = create_async_engine( engine = create_async_engine(
DATABASE_URL, DATABASE_URL,
pool_pre_ping=True, pool_pre_ping=True,
@@ -29,3 +33,13 @@ engine = create_async_engine(
connect_args=connect_args, connect_args=connect_args,
) )
async_session_maker = async_sessionmaker(engine, expire_on_commit=False) async_session_maker = async_sessionmaker(engine, expire_on_commit=False)
# Synchronous engine/session for sync utilities (e.g., bank_scraper)
SYNC_DATABASE_URL = DATABASE_URL.replace("+asyncmy", "+pymysql")
engine_sync = create_engine(
SYNC_DATABASE_URL,
pool_pre_ping=True,
echo=os.getenv("SQL_ECHO", "0") == "1",
connect_args=connect_args,
)
sync_session_maker = sessionmaker(bind=engine_sync, expire_on_commit=False)

View File

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

View File

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

View File

@@ -21,4 +21,4 @@ class Transaction(Base):
# Relationship # Relationship
user = relationship("User", back_populates="transactions") user = relationship("User", back_populates="transactions")
categories = relationship("Category", secondary=association_table, back_populates="transactions") categories = relationship("Category", secondary=association_table, back_populates="transactions", passive_deletes=True)

View File

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

@@ -1,9 +1,10 @@
import logging import logging
import asyncio import os
import smtplib
from celery import shared_task from email.message import EmailMessage
import app.services.bank_scraper import app.services.bank_scraper
from app.celery_app import celery_app
logger = logging.getLogger("celery_tasks") logger = logging.getLogger("celery_tasks")
if not logger.handlers: if not logger.handlers:
@@ -12,96 +13,74 @@ if not logger.handlers:
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
def run_coro(coro) -> None: @celery_app.task(name="workers.send_email")
"""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: def send_email(to: str, subject: str, body: str) -> None:
if not (to and subject and body): 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) logger.error("Email task missing fields. to=%r subject=%r body_len=%r", to, subject, len(body) if body else 0)
return return
# Placeholder for real email sending logic host = os.getenv("SMTP_HOST")
logger.info("[Celery] Email sent | to=%s | subject=%s | body_len=%d", to, subject, len(body)) 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") @celery_app.task(name="workers.load_transactions")
def load_transactions(user_id: str) -> None: def load_transactions(user_id: str) -> None:
if not user_id: if not user_id:
logger.error("Load transactions task missing user_id.") logger.error("Load transactions task missing user_id.")
return return
run_coro(app.services.bank_scraper.aload_ceska_sporitelna_transactions(user_id)) logger.info("[Celery] Starting load_transactions | user_id=%s", user_id)
try:
# Placeholder for real transaction loading logic # Use synchronous bank scraper functions directly, mirroring load_all_transactions
logger.info("[Celery] Transactions loaded for user_id=%s", user_id) app.services.bank_scraper.load_mock_bank_transactions(user_id)
app.services.bank_scraper.load_ceska_sporitelna_transactions(user_id)
except Exception:
logger.exception("Failed to load transactions for user_id=%s", user_id)
else:
logger.info("[Celery] Finished load_transactions | user_id=%s", user_id)
@shared_task(name="workers.load_all_transactions") @celery_app.task(name="workers.load_all_transactions")
def load_all_transactions() -> None: def load_all_transactions() -> None:
logger.info("[Celery] Starting load_all_transactions") logger.info("[Celery] Starting load_all_transactions")
run_coro(app.services.bank_scraper.aload_all_ceska_sporitelna_transactions()) # Now use synchronous bank scraper functions directly
app.services.bank_scraper.load_all_mock_bank_transactions()
app.services.bank_scraper.load_all_ceska_sporitelna_transactions()
logger.info("[Celery] Finished load_all_transactions") logger.info("[Celery] Finished load_all_transactions")

View File

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

View File

@@ -38,6 +38,8 @@ MarkupSafe==3.0.2
multidict==6.6.4 multidict==6.6.4
packaging==25.0 packaging==25.0
pamqp==3.3.0 pamqp==3.3.0
prometheus-fastapi-instrumentator==7.1.0
prometheus_client==0.23.1
prompt_toolkit==3.0.52 prompt_toolkit==3.0.52
propcache==0.3.2 propcache==0.3.2
pwdlib==0.2.1 pwdlib==0.2.1
@@ -68,3 +70,4 @@ watchfiles==1.1.0
wcwidth==0.2.14 wcwidth==0.2.14
websockets==15.0.1 websockets==15.0.1
yarl==1.20.1 yarl==1.20.1
python-json-logger==2.0.7

View File

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

View File

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

View File

@@ -1,15 +0,0 @@
from fastapi import status
def test_e2e_minimal_auth_flow(client):
# 1) Service is alive
alive = client.get("/")
assert alive.status_code == status.HTTP_200_OK
# 2) Attempt to login without payload should fail fast (validation error)
login = client.post("/auth/jwt/login")
assert login.status_code in (status.HTTP_400_BAD_REQUEST, status.HTTP_422_UNPROCESSABLE_CONTENT)
# 3) Protected endpoint should not be accessible without token
me = client.get("/users/me")
assert me.status_code in (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN)

View File

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

View File

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

View File

@@ -8,10 +8,12 @@ spec:
selector: selector:
matchLabels: matchLabels:
app: {{ .Values.app.name }} app: {{ .Values.app.name }}
endpoint: metrics
template: template:
metadata: metadata:
labels: labels:
app: {{ .Values.app.name }} app: {{ .Values.app.name }}
endpoint: metrics
spec: spec:
containers: containers:
- name: {{ .Values.app.name }} - name: {{ .Values.app.name }}
@@ -88,6 +90,11 @@ spec:
secretKeyRef: secretKeyRef:
name: prod name: prod
key: CSAS_CLIENT_SECRET key: CSAS_CLIENT_SECRET
- name: UNIRATE_API_KEY
valueFrom:
secretKeyRef:
name: prod
key: UNIRATE_API_KEY
- name: DOMAIN - name: DOMAIN
value: {{ required "Set .Values.domain" .Values.domain | quote }} value: {{ required "Set .Values.domain" .Values.domain | quote }}
- name: DOMAIN_SCHEME - name: DOMAIN_SCHEME

View File

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

View File

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

@@ -19,3 +19,11 @@ stringData:
RABBITMQ_USERNAME: {{ .Values.rabbitmq.username | quote }} RABBITMQ_USERNAME: {{ .Values.rabbitmq.username | quote }}
SENTRY_DSN: {{ .Values.sentry_dsn | quote }} SENTRY_DSN: {{ .Values.sentry_dsn | quote }}
DB_ENCRYPTION_KEY: {{ required "Set .Values.database.encryptionSecret" .Values.database.encryptionSecret | quote }} DB_ENCRYPTION_KEY: {{ required "Set .Values.database.encryptionSecret" .Values.database.encryptionSecret | quote }}
SMTP_HOST: {{ .Values.smtp.host | default "" | quote }}
SMTP_PORT: {{ .Values.smtp.port | default 587 | quote }}
SMTP_USERNAME: {{ .Values.smtp.username | default "" | quote }}
SMTP_PASSWORD: {{ .Values.smtp.password | default "" | quote }}
SMTP_USE_TLS: {{ .Values.smtp.tls | default false | quote }}
SMTP_USE_SSL: {{ .Values.smtp.ssl | default false | quote }}
SMTP_FROM: {{ .Values.smtp.from | default "" | quote }}
UNIRATE_API_KEY: {{ .Values.unirate.key | default "" | quote }}

View File

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

View File

@@ -85,3 +85,38 @@ spec:
secretKeyRef: secretKeyRef:
name: prod name: prod
key: DB_ENCRYPTION_KEY 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: worker:
replicas: 3 replicas: 3
cron:
enabled: true

View File

@@ -13,6 +13,9 @@ deployment: ""
domain: "" domain: ""
domain_scheme: "" domain_scheme: ""
unirate:
key: ""
frontend_domain: "" frontend_domain: ""
frontend_domain_scheme: "" frontend_domain_scheme: ""
@@ -35,6 +38,23 @@ worker:
# Queue name for Celery worker and for CRD Queue # Queue name for Celery worker and for CRD Queue
mailQueueName: "mail_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: service:
port: 80 port: 80

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,9 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { type Category, type Transaction, type BalancePoint, createTransaction, getCategories, getTransactions, createCategory, updateTransaction, getBalanceSeries } from '../api'; import { type Category, type Transaction, type BalancePoint, deleteTransaction, getCategories, getTransactions, createTransaction, updateTransaction, getBalanceSeries } from '../api';
import AccountPage from './AccountPage'; import AccountPage from './AccountPage';
import AppearancePage from './AppearancePage'; import AppearancePage from './AppearancePage';
import BalanceChart from './BalanceChart'; import BalanceChart from './BalanceChart';
import ManualManagement from './ManualManagement';
import CategoryPieChart from './CategoryPieChart'; import CategoryPieChart from './CategoryPieChart';
import MockBankModal, { type MockGenerationOptions } from './MockBankModal'; import MockBankModal, { type MockGenerationOptions } from './MockBankModal';
import { BACKEND_URL } from '../config'; import { BACKEND_URL } from '../config';
@@ -11,8 +12,105 @@ function formatAmount(n: number) {
return new Intl.NumberFormat(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n); return new Intl.NumberFormat(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n);
} }
//https://unirateapi.com/
// Define the structure for the rate data we care about
type RateData = {
currencyCode: string;
rate: number;
};
// The 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);
try {
const base = BACKEND_URL.replace(/\/$/, '');
const url = `${base}/exchange-rates?symbols=${TARGET_CURRENCIES.join(',')}`;
const token = localStorage.getItem('token');
const res = await fetch(url, {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
credentials: 'include',
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `Failed to load rates (${res.status})`);
}
const data: RateData[] = await res.json();
setRates(data);
} catch (err: any) {
setError(err.message || 'Could not load rates');
} finally {
setLoading(false);
}
}
fetchRates();
}, []); // Runs once on component mount
return (
// This component will push itself to the bottom of the sidebar
<div
className="currency-rates"
style={{
padding: '0 1.5rem',
marginTop: 'auto', // Pushes to bottom
paddingBottom: '1.5rem' // Adds some spacing at the end
}}
>
<h4 style={{
margin: '1.5rem 0 0.75rem 0',
color: '#8a91b4', // Muted color to match dark sidebar
fontWeight: 500,
fontSize: '0.9em',
textTransform: 'uppercase',
}}>
Rates (vs CZK)
</h4>
{loading && <div style={{ fontSize: '0.9em', color: '#ccc' }}>Loading...</div>}
{error && <div style={{ fontSize: '0.9em', color: 'crimson' }}>{error}</div>}
{!loading && !error && (
<ul style={{ listStyle: 'none', padding: 0, margin: 0, fontSize: '0.9em', color: '#fff' }}>
{rates.length > 0 ? rates.map(rate => (
<li key={rate.currencyCode} style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
<strong>{rate.currencyCode}</strong>
<span>{rate.rate.toFixed(3)}</span>
</li>
)) : <li style={{color: '#8a91b4'}}>No rates found.</li>}
</ul>
)}
<a
href="https://unirateapi.com"
target="_blank"
rel="noopener noreferrer"
style={{
display: 'block',
marginTop: '1rem',
fontSize: '0.8em',
color: '#8a91b4', // Muted color
textDecoration: 'none'
}}
>
Exchange Rates By UniRateAPI
</a>
</div>
);
}
export default function Dashboard({ onLogout }: { onLogout: () => void }) { export default function Dashboard({ onLogout }: { onLogout: () => void }) {
const [current, setCurrent] = useState<'home' | 'account' | 'appearance'>('home'); const [current, setCurrent] = useState<'home' | 'manual' | 'account' | 'appearance'>('home');
const [transactions, setTransactions] = useState<Transaction[]>([]); const [transactions, setTransactions] = useState<Transaction[]>([]);
const [categories, setCategories] = useState<Category[]>([]); const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -41,11 +139,6 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
} }
} }
// New transaction form state
const [amount, setAmount] = useState<string>('');
const [description, setDescription] = useState('');
const [selectedCategoryId, setSelectedCategoryId] = useState<number | ''>('');
// Filters // Filters
const [minAmount, setMinAmount] = useState<string>(''); const [minAmount, setMinAmount] = useState<string>('');
const [maxAmount, setMaxAmount] = useState<string>(''); const [maxAmount, setMaxAmount] = useState<string>('');
@@ -63,16 +156,19 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
// Balance chart series for current date filter // Balance chart series for current date filter
const [balanceSeries, setBalanceSeries] = useState<BalancePoint[]>([]); const [balanceSeries, setBalanceSeries] = useState<BalancePoint[]>([]);
// Category creation form // Manual forms moved to ManualManagement page
const [newCatName, setNewCatName] = useState('');
const [newCatDesc, setNewCatDesc] = useState('');
// New transaction date // Inline edit state for transaction editing
const [txDate, setTxDate] = useState<string>('');
// Inline edit state for transaction categories
const [editingTxId, setEditingTxId] = useState<number | null>(null); const [editingTxId, setEditingTxId] = useState<number | null>(null);
const [editingCategoryIds, setEditingCategoryIds] = useState<number[]>([]); const [editingCategoryIds, setEditingCategoryIds] = useState<number[]>([]);
const [editingAmount, setEditingAmount] = useState<string>('');
const [editingDescription, setEditingDescription] = useState<string>('');
const [editingDate, setEditingDate] = useState<string>(''); // YYYY-MM-DD
// Sidebar toggle for mobile
const [sidebarOpen, setSidebarOpen] = useState(false);
async function loadAll() { async function loadAll() {
setLoading(true); setLoading(true);
@@ -99,44 +195,50 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
setIsGenerating(true); setIsGenerating(true);
setMockModalOpen(false); setMockModalOpen(false);
const { count, minAmount, maxAmount, startDate, endDate, categoryIds } = options; try {
const newTransactions: Transaction[] = []; const base = BACKEND_URL.replace(/\/$/, '');
const url = `${base}/mock-bank/generate`;
const startDateTime = new Date(startDate).getTime(); const token = localStorage.getItem('token');
const endDateTime = new Date(endDate).getTime(); const res = await fetch(url, {
method: 'POST',
for (let i = 0; i < count; i++) { headers: {
// Generate random data based on user input 'Content-Type': 'application/json',
const amount = parseFloat((Math.random() * (maxAmount - minAmount) + minAmount).toFixed(2)); ...(token ? { Authorization: `Bearer ${token}` } : {}),
},
const randomTime = Math.random() * (endDateTime - startDateTime) + startDateTime; credentials: 'include',
const date = new Date(randomTime); body: JSON.stringify(options),
const dateString = date.toISOString().split('T')[0]; });
if (!res.ok) {
const randomCategory = categoryIds.length > 0 const text = await res.text();
? [categoryIds[Math.floor(Math.random() * categoryIds.length)]] throw new Error(text || `Failed to generate mock transactions (${res.status})`);
: [];
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;
} }
const generated: Array<{ amount: number; date: string; category_ids: number[]; description?: string | null }>
= await res.json();
const newTransactions: Transaction[] = [];
for (const g of generated) {
try {
const created = await createTransaction({
amount: g.amount,
date: g.date,
category_ids: g.category_ids || [],
description: g.description || undefined,
});
newTransactions.push(created);
} catch (err) {
console.error('Failed to create mock transaction:', err);
// continue creating others
}
}
alert(`${newTransactions.length} mock transactions were successfully generated!`);
} catch (err: any) {
console.error(err);
alert(err?.message || 'Failed to generate mock transactions');
} finally {
setIsGenerating(false);
await loadAll();
} }
setIsGenerating(false);
alert(`${newTransactions.length} mock transactions were successfully generated!`);
await loadAll();
} }
useEffect(() => { loadAll(); }, [startDate, endDate]); useEffect(() => { loadAll(); }, [startDate, endDate]);
@@ -167,56 +269,79 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
function categoryNameById(id: number) { return categories.find(c => c.id === id)?.name || `#${id}`; } function categoryNameById(id: number) { return categories.find(c => c.id === id)?.name || `#${id}`; }
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!amount) return;
const payload = {
amount: Number(amount),
description: description || undefined,
category_ids: selectedCategoryId !== '' ? [Number(selectedCategoryId)] : undefined,
date: txDate || undefined,
};
try {
const created = await createTransaction(payload);
setTransactions(prev => [created, ...prev]);
setAmount(''); setDescription(''); setSelectedCategoryId(''); setTxDate('');
} catch (err: any) {
alert(err?.message || 'Failed to create transaction');
}
}
function beginEditCategories(t: Transaction) { function beginEditTransaction(t: Transaction) {
setEditingTxId(t.id); setEditingTxId(t.id);
setEditingCategoryIds([...(t.category_ids || [])]); setEditingCategoryIds([...(t.category_ids || [])]);
setEditingAmount(String(t.amount));
setEditingDescription(t.description || '');
setEditingDate(t.date || '');
} }
function cancelEditCategories() { function cancelEditTransaction() {
setEditingTxId(null); setEditingTxId(null);
setEditingCategoryIds([]); setEditingCategoryIds([]);
setEditingAmount('');
setEditingDescription('');
setEditingDate('');
} }
async function saveEditCategories() { async function saveEditTransaction() {
if (editingTxId == null) return; if (editingTxId == null) return;
const amountNum = Number(editingAmount);
if (Number.isNaN(amountNum)) {
alert('Amount must be a number.');
return;
}
try { try {
const updated = await updateTransaction(editingTxId, { category_ids: editingCategoryIds }); const updated = await updateTransaction(editingTxId, {
amount: amountNum,
description: editingDescription,
date: editingDate || undefined,
category_ids: editingCategoryIds,
});
setTransactions(prev => prev.map(p => (p.id === updated.id ? updated : p))); setTransactions(prev => prev.map(p => (p.id === updated.id ? updated : p)));
cancelEditCategories(); // Optionally refresh balance series to reflect changes immediately
try { setBalanceSeries(await getBalanceSeries(startDate || undefined, endDate || undefined)); } catch {}
cancelEditTransaction();
} catch (err: any) { } catch (err: any) {
alert(err?.message || 'Failed to update transaction categories'); alert(err?.message || 'Failed to update transaction');
}
}
async function handleDeleteTransaction(id: number) {
if (!confirm('Delete this transaction? This cannot be undone.')) return;
try {
await deleteTransaction(id);
setTransactions(prev => prev.filter(t => t.id !== id));
try { setBalanceSeries(await getBalanceSeries(startDate || undefined, endDate || undefined)); } catch {}
} catch (err: any) {
alert(err?.message || 'Failed to delete transaction');
} }
} }
return ( return (
<div className="app-layout"> <div className={`app-layout ${sidebarOpen ? 'sidebar-open' : ''}`}>
<aside className="sidebar"> <aside className="sidebar" style={{ display: 'flex', flexDirection: 'column' }}>
<div className="logo">7Project</div> <div>
<nav className="nav"> <div className="logo">Finance Tracker</div>
<button className={current === 'home' ? 'active' : ''} onClick={() => setCurrent('home')}>Home</button> <nav className="nav" onClick={() => setSidebarOpen(false)}>
<button className={current === 'account' ? 'active' : ''} onClick={() => setCurrent('account')}>Account</button> <button className={current === 'home' ? 'active' : ''} onClick={() => setCurrent('home')}>Home</button>
<button className={current === 'appearance' ? 'active' : ''} onClick={() => setCurrent('appearance')}>Appearance</button> <button className={current === 'manual' ? 'active' : ''} onClick={() => setCurrent('manual')}>Manual management</button>
</nav> <button className={current === 'account' ? 'active' : ''} onClick={() => setCurrent('account')}>Account</button>
<button className={current === 'appearance' ? 'active' : ''} onClick={() => setCurrent('appearance')}>Appearance</button>
</nav>
</div>
<CurrencyRates />
</aside> </aside>
<div className="content"> <div className="content">
<div className="topbar"> <div className="topbar">
<h2 style={{ margin: 0 }}>{current === 'home' ? 'Dashboard' : current === 'account' ? 'Account' : 'Appearance'}</h2> <button
className="icon-btn hamburger"
aria-label="Open menu"
aria-expanded={sidebarOpen}
onClick={() => setSidebarOpen(true)}
></button>
<h2 style={{ margin: 0 }}>{current === 'home' ? 'Dashboard' : current === 'manual' ? 'Manual management' : current === 'account' ? 'Account' : 'Appearance'}</h2>
<div className="actions"> <div className="actions">
<span className="user muted">Signed in</span> <span className="user muted">Signed in</span>
<button className="btn" onClick={onLogout}>Logout</button> <button className="btn" onClick={onLogout}>Logout</button>
@@ -237,28 +362,7 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
</div> </div>
</section> </section>
<section className="card">
<h3>Add Transaction</h3>
<form onSubmit={handleCreate} className="form-row">
<input className="input" type="number" step="0.01" placeholder="Amount" value={amount} onChange={(e) => setAmount(e.target.value)} required />
<input className="input" type="date" placeholder="Date (optional)" value={txDate} onChange={(e) => setTxDate(e.target.value)} />
<input className="input" type="text" placeholder="Description (optional)" value={description} onChange={(e) => setDescription(e.target.value)} />
<select className="input" value={selectedCategoryId} onChange={(e) => setSelectedCategoryId(e.target.value ? Number(e.target.value) : '')}>
<option value="">No category</option>
{categories.map(c => (<option key={c.id} value={c.id}>{c.name}</option>))}
</select>
<button className="btn primary" type="submit">Add</button>
</form>
</section>
<section className="card">
<h3>Categories</h3>
<form className="form-row" onSubmit={async (e) => { e.preventDefault(); if (!newCatName.trim()) return; try { const cat = await createCategory({ name: newCatName.trim(), description: newCatDesc || undefined }); setCategories(prev => [...prev, cat]); setNewCatName(''); setNewCatDesc(''); } catch (err: any) { alert(err?.message || 'Failed to create category'); } }}>
<input className="input" type="text" placeholder="New category name" value={newCatName} onChange={(e) => setNewCatName(e.target.value)} />
<input className="input" type="text" placeholder="Description (optional)" value={newCatDesc} onChange={(e) => setNewCatDesc(e.target.value)} />
<button className="btn primary" type="submit">Create category</button>
</form>
</section>
<section className="card"> <section className="card">
<h3>Filters</h3> <h3>Filters</h3>
@@ -317,39 +421,98 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
<button className="btn primary" disabled={page >= totalPages - 1} onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}>Next</button> <button className="btn primary" disabled={page >= totalPages - 1} onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}>Next</button>
</div> </div>
</div> </div>
<table className="table"> <table className="table responsive">
<thead> <thead>
<tr> <tr>
<th>Date</th> <th>Date</th>
<th style={{ textAlign: 'right' }}>Amount</th> <th style={{ textAlign: 'right' }}>Amount</th>
<th>Description</th> <th>Description</th>
<th>Categories</th> <th>Categories</th>
<th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{visible.map(t => ( {visible.map(t => (
<tr key={t.id}> <tr key={t.id}>
<td>{t.date || ''}</td> {/* Date cell */}
<td className="amount">{formatAmount(t.amount)}</td> <td data-label="Date">
<td>{t.description || ''}</td>
<td>
{editingTxId === t.id ? ( {editingTxId === t.id ? (
<div className="space-y" style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <input
<select multiple className="input" value={editingCategoryIds.map(String)} onChange={(e) => { className="input"
const opts = Array.from(e.currentTarget.selectedOptions).map(o => Number(o.value)); type="date"
setEditingCategoryIds(opts); value={editingDate}
}}> onChange={(e) => setEditingDate(e.target.value)}
/>
) : (
t.date || ''
)}
</td>
{/* Amount cell */}
<td data-label="Amount" className="amount" style={{ textAlign: 'right' }}>
{editingTxId === t.id ? (
<input
className="input"
type="number"
step="0.01"
value={editingAmount}
onChange={(e) => setEditingAmount(e.target.value)}
style={{ textAlign: 'right' }}
/>
) : (
formatAmount(t.amount)
)}
</td>
{/* Description cell */}
<td data-label="Description">
{editingTxId === t.id ? (
<input
className="input"
type="text"
value={editingDescription}
onChange={(e) => setEditingDescription(e.target.value)}
/>
) : (
t.description || ''
)}
</td>
{/* Categories cell */}
<td data-label="Categories">
{editingTxId === t.id ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<select
multiple
className="input"
value={editingCategoryIds.map(String)}
onChange={(e) => {
const opts = Array.from(e.currentTarget.selectedOptions).map(o => Number(o.value));
setEditingCategoryIds(opts);
}}
>
{categories.map(c => ( {categories.map(c => (
<option key={c.id} value={c.id}>{c.name}</option> <option key={c.id} value={c.id}>{c.name}</option>
))} ))}
</select> </select>
<button className="btn small" onClick={saveEditCategories}>Save</button>
<button className="btn small" onClick={cancelEditCategories}>Cancel</button>
</div> </div>
) : ( ) : (
<div className="space-x" style={{ display: 'flex', alignItems: 'center', gap: 8, justifyContent: 'space-between' }}> <span>{t.category_ids.map(id => categoryNameById(id)).join(', ') || '—'}</span>
<span>{t.category_ids.map(id => categoryNameById(id)).join(', ') || '—'}</span> )}
<button className="btn small" onClick={() => beginEditCategories(t)}>Change</button> </td>
{/* Actions cell */}
<td data-label="Actions">
{editingTxId === t.id ? (
<div className="actions" style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button className="btn small" onClick={saveEditTransaction}>Save</button>
<button className="btn small" onClick={cancelEditTransaction}>Cancel</button>
<button className="btn small" onClick={() => handleDeleteTransaction(t.id)}>Delete</button>
</div>
) : (
<div className="actions" style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button className="btn small" onClick={() => beginEditTransaction(t)}>Edit</button>
<button className="btn small" onClick={() => handleDeleteTransaction(t.id)}>Delete</button>
</div> </div>
)} )}
</td> </td>
@@ -368,6 +531,14 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
<AccountPage onDeleted={onLogout} /> <AccountPage onDeleted={onLogout} />
)} )}
{current === 'manual' && (
<ManualManagement
categories={categories}
onTransactionAdded={(t) => setTransactions(prev => [t, ...prev])}
onCategoryCreated={(c) => setCategories(prev => [...prev, c])}
/>
)}
{current === 'appearance' && ( {current === 'appearance' && (
<AppearancePage /> <AppearancePage />
)} )}
@@ -380,6 +551,7 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
onClose={() => setMockModalOpen(false)} onClose={() => setMockModalOpen(false)}
onGenerate={handleGenerateMockTransactions} onGenerate={handleGenerateMockTransactions}
/> />
{sidebarOpen && <div className="backdrop" onClick={() => setSidebarOpen(false)} />}
</div> </div>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,51 @@
# Weekly Meeting Notes
- Group 8 - Personal finance tracker
- Mentor: Jaychander
Keep all meeting notes in the `meetings.md` file in your project folder.
Just copy the template below for each weekly meeting and fill in the details.
## Administrative Info
- Date: 2025-10-30
- Attendees: Dejan, Lukas
- Notetaker: Dejan
## Progress Update (Before Meeting)
Last 3 minutes of the meeting, summarize action items.
- [ ] Dont store data in database (security) - Load it on login (from CSAS API and local database), load automatically with email
- [X] Go through the checklist
- [X] Look for possible APIs (like stocks or financial details whatever)
- [ ] Report - partly
Summary of what has been accomplished since the last meeting in the following categories.
### Coding
Implemented CSAS API transactions fetch, Added tests with testing database on github actions, redone UI,
added currency exchange rate with CNB API
### Documentation
Not much - just updated the work done
## Questions and Topics for Discussion (Before Meeting)
1. Security regarding storing transactions - possibility of encryption
2. Realisticaly what needs to be done for us to be done
3. Question 3
## Discussion Notes (During Meeting)
The tracker should not store the transactions in the database - security vulnerability.
## Action Items for Next Week (During Meeting)
Last 3 minutes of the meeting, summarize action items.
- [x] Change the name on frontend from 7project
- [x] Finalize the funcionality and everyting in the code part
- [ ] Try to finalize report with focus on reproducibility
- [ ] More high level explanation of the workflow in the report
---

View File

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

View File

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

View File

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