Compare commits

53 Commits

Author SHA1 Message Date
3b6b64d472 update report.md 2025-10-16 13:51:52 +02:00
ribardej
9bc543a5fa feat(docs): weekly meeting 2025-10-16 13:27:53 +02:00
ribardej
14516a808b feat(docs): this week meeting.md 2025-10-16 11:15:54 +02:00
ribardej
922ebf46ae feat(docs): Catch up on report.md 2025-10-15 16:25:28 +02:00
e200c73b47 fix(backend): use correct variable to register routers
Some checks failed
Deploy Prod / Build and push image (reusable) (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-13 17:11:31 +02:00
Dejan Ribarovski
ac10ab381e Merge pull request #26 from dat515-2025/20-create-a-controller-layer-on-backend-side
20 create a controller layer on backend side
2025-10-13 14:05:05 +02:00
Dejan Ribarovski
879109144c Merge branch 'main' into 20-create-a-controller-layer-on-backend-side 2025-10-13 14:03:24 +02:00
ribardej
7061e57442 Merge remote-tracking branch 'origin/20-create-a-controller-layer-on-backend-side' into 20-create-a-controller-layer-on-backend-side 2025-10-13 13:57:04 +02:00
ribardej
30068079c6 feat(backend): renamed endpoints for consistency 2025-10-13 13:56:44 +02:00
Dejan Ribarovski
9580bea630 Update 7project/backend/app/api/transactions.py
Better error message

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-13 13:52:36 +02:00
Dejan Ribarovski
975f5e5bec Update 7project/backend/app/api/transactions.py
Better error message

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-13 13:52:24 +02:00
ribardej
f1065bc274 feat(backend): update consistent Pydantic v2 use everywhere 2025-10-13 13:50:59 +02:00
Dejan Ribarovski
12152238c6 Merge pull request #23 from dat515-2025/merge/oauth
Some checks are pending
Deploy Prod / Build and push image (reusable) (push) Waiting to run
Deploy Prod / Frontend - Build and Deploy to Cloudflare Pages (prod) (push) Waiting to run
Deploy Prod / Helm upgrade/install (prod) (push) Blocked by required conditions
feat(auth): add support for OAuth and MojeID
2025-10-13 12:46:17 +02:00
Dejan Ribarovski
21ef5a3961 Merge pull request #25 from dat515-2025/merge/database_backups
feat(infrastructure): add backups
2025-10-13 12:41:27 +02:00
ribardej
2f20fb12e4 feat(backend): implemented basic controller layer 2025-10-13 12:07:47 +02:00
bf213234b1 feat(infrastructure): add backups 2025-10-12 20:14:48 +02:00
95c8bf1e92 Update 7project/backend/app/app.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-11 22:25:04 +02:00
b213f22a15 feat(auth): refactor 2025-10-11 22:22:36 +02:00
0cf06b7bd9 feat(auth): add CustomOpenID class to force get_user_info implementation 2025-10-11 21:37:49 +02:00
7a67b12533 Update 7project/backend/alembic/versions/2025_10_11_2107-5ab2e654c96e_change_token_lenght.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-11 21:32:03 +02:00
a91aea805f feat(auth): add BankID OAuth provider 2025-10-11 21:16:53 +02:00
32764ab1b0 feat(auth): allow updating custom fields from oauth, update MojeID 2025-10-11 20:34:36 +02:00
ribardej
6c248039ac feat(backend): fixed DB user schema 2025-10-10 16:16:43 +02:00
df0f2584ae feat(auth): add support for OAuth and MojeID 2025-10-10 15:58:40 +02:00
b7570e334f feat(auth): add support for OAuth and MojeID 2025-10-10 15:51:18 +02:00
4ea6876b74 feat(infrastructure): add forgotten values.yaml 2025-10-10 13:57:43 +02:00
6d5dd1a222 feat(infrastructure): update deployment
Some checks failed
Deploy Prod / Build and push image (reusable) (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-09 18:51:17 +02:00
ribardej
f09f9eaa82 feat(infrastructure): redone the system diagram 2025-10-09 15:55:23 +02:00
ae10c4daff Merge pull request #19 from dat515-2025/merge/basic_database_structure
feat(models): add basic database structure
2025-10-09 15:24:11 +02:00
abebdb019b feat(models): change unique index 2025-10-09 15:15:24 +02:00
6040f4339c feat(models): database changes 2025-10-09 15:09:26 +02:00
72c241f4f7 feat(infrastructure): database changes 2025-10-09 15:07:33 +02:00
8db669ac72 feat(infrastructure): database changes 2025-10-09 14:56:51 +02:00
e32e18f0de feat(models): add basic database structure 2025-10-09 14:41:11 +02:00
95996d22f8 feat(models): add basic database structure 2025-10-09 14:33:07 +02:00
ribardej
991c070918 meeting notes 2025-10-09 13:57:45 +02:00
derib2613
a717e4afeb Merge pull request #17 from dat515-2025/11-update-deployment
update
2025-10-09 12:43:45 +02:00
ribardej
2bc03bcd5b feat(infrastructure): add documentation markdown files 2025-10-08 17:17:28 +02:00
dbd37a8b83 feat(infrastructure): add frontend, deploy to cloudflare 2025-10-06 21:36:30 +02:00
f1cbdbce9c update 2025-10-06 21:29:35 +02:00
fa1b9523a1 feat(infrastructure): add frontend, deploy to cloudflare
Some checks failed
Deploy Prod / Build and push image (reusable) (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-06 20:56:42 +02:00
e5fceb886b feat(infrastructure): add frontend, deploy to cloudflare 2025-10-06 18:48:01 +02:00
ec7c0cbc7a Merge pull request #16 from dat515-2025/merge/cloudflare-deploy
feat(infrastructure): add frontend, deploy to cloudflare
2025-10-06 18:44:31 +02:00
9ea02ed10c feat(infrastructure): add frontend, deploy to cloudflare 2025-10-06 18:39:38 +02:00
afb8199cad feat(infrastructure): add frontend, deploy to cloudflare 2025-10-06 18:37:35 +02:00
1e23b32f30 feat(infrastructure): add frontend, deploy to cloudflare 2025-10-06 18:26:17 +02:00
cdfaf3e66d feat(infrastructure): add frontend, deploy to cloudflare 2025-10-06 18:23:52 +02:00
21ccb00f4a feat(infrastructure): add frontend, deploy to cloudflare 2025-10-06 18:12:39 +02:00
901fff8651 feat(infrastructure): add frontend, deploy to cloudflare 2025-10-06 18:09:23 +02:00
9c4144f5c4 feat(infrastructure): add frontend, deploy to cloudflare 2025-10-06 18:05:32 +02:00
37f4d44caf Merge remote-tracking branch 'origin/main' 2025-10-06 17:40:51 +02:00
d0ffab97c3 feat(infrastructure): add metrics server 2025-10-06 17:40:40 +02:00
ribardej
b6f8daba8c rabbitmq legacy support 2025-10-06 16:30:50 +02:00
69 changed files with 5674 additions and 107 deletions

View File

@@ -20,6 +20,15 @@ jobs:
pr_number: ${{ github.event.pull_request.number }} pr_number: ${{ github.event.pull_request.number }}
secrets: inherit secrets: inherit
frontend:
if: github.event.action != 'closed'
name: Frontend - Build and Deploy to Cloudflare Pages (PR)
uses: ./.github/workflows/frontend-pages.yml
with:
mode: pr
pr_number: ${{ github.event.pull_request.number }}
secrets: inherit
deploy: deploy:
if: github.event.action != 'closed' if: github.event.action != 'closed'
name: Helm upgrade/install (PR preview) name: Helm upgrade/install (PR preview)
@@ -27,7 +36,7 @@ jobs:
concurrency: concurrency:
group: pr-${{ github.event.pull_request.number }} group: pr-${{ github.event.pull_request.number }}
cancel-in-progress: false cancel-in-progress: false
needs: [build] needs: [build, frontend]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -51,8 +60,8 @@ jobs:
- name: Helm upgrade/install PR preview - name: Helm upgrade/install PR preview
env: env:
DEV_BASE_DOMAIN: ${{ secrets.BASE_DOMAIN }} DEV_BASE_DOMAIN: ${{ secrets.BASE_DOMAIN }}
RABBITMQ_PASSWORD: ${{ secrets.RABBITMQ_PASSWORD }} RABBITMQ_PASSWORD: ${{ secrets.PROD_RABBITMQ_PASSWORD }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }} DB_PASSWORD: ${{ secrets.PROD_DB_PASSWORD }}
IMAGE_REPO: ${{ needs.build.outputs.image_repo }} IMAGE_REPO: ${{ needs.build.outputs.image_repo }}
DIGEST: ${{ needs.build.outputs.digest }} DIGEST: ${{ needs.build.outputs.digest }}
run: | run: |
@@ -76,10 +85,11 @@ jobs:
--set-string rabbitmq.password="$RABBITMQ_PASSWORD" \ --set-string rabbitmq.password="$RABBITMQ_PASSWORD" \
--set-string database.password="$DB_PASSWORD" --set-string database.password="$DB_PASSWORD"
- name: Post preview URL as PR comment - name: Post preview URLs as PR comment
uses: actions/github-script@v7 uses: actions/github-script@v7
env: env:
DEV_BASE_DOMAIN: ${{ secrets.BASE_DOMAIN }} DEV_BASE_DOMAIN: ${{ secrets.BASE_DOMAIN }}
FRONTEND_URL: ${{ needs.frontend.outputs.deployed_url }}
with: with:
script: | script: |
const pr = context.payload.pull_request; const pr = context.payload.pull_request;
@@ -87,10 +97,11 @@ jobs:
const prNumber = pr.number; const prNumber = pr.number;
const domainBase = process.env.DEV_BASE_DOMAIN; const domainBase = process.env.DEV_BASE_DOMAIN;
if (!domainBase) { core.setFailed('DEV_BASE_DOMAIN is required'); return; } if (!domainBase) { core.setFailed('DEV_BASE_DOMAIN is required'); return; }
const domain = `pr-${prNumber}.${domainBase}`; const backendDomain = `pr-${prNumber}.${domainBase}`;
const url = `https://${domain}`; const backendUrl = `https://${backendDomain}`;
const frontendUrl = process.env.FRONTEND_URL || '(not available)';
const marker = '<!-- preview-link -->'; const marker = '<!-- preview-link -->';
const body = `${marker}\nPreview environment is running: ${url}\n`; const body = `${marker}\nPreview environment is running\n- Frontend: ${frontendUrl}\n- Backend: ${backendUrl}\n`;
const { owner, repo } = context.repo; const { owner, repo } = context.repo;
const { data: comments } = await github.rest.issues.listComments({ owner, repo, issue_number: prNumber, per_page: 100 }); const { data: comments } = await github.rest.issues.listComments({ owner, repo, issue_number: prNumber, per_page: 100 });
const existing = comments.find(c => c.body && c.body.includes(marker)); const existing = comments.find(c => c.body && c.body.includes(marker));
@@ -103,7 +114,7 @@ jobs:
uninstall: uninstall:
if: github.event.action == 'closed' if: github.event.action == 'closed'
name: Helm uninstall (PR preview) name: Helm uninstall (PR preview)
runs-on: ubuntu-latest runs-on: vhs
steps: steps:
- name: Setup Helm - name: Setup Helm
uses: azure/setup-helm@v4 uses: azure/setup-helm@v4

View File

@@ -5,9 +5,11 @@ on:
branches: [ "main" ] branches: [ "main" ]
paths: paths:
- 7project/backend/** - 7project/backend/**
- 7project/frontend/**
- 7project/charts/myapp-chart/** - 7project/charts/myapp-chart/**
- .github/workflows/deploy-prod.yaml - .github/workflows/deploy-prod.yaml
- .github/workflows/build-image.yaml - .github/workflows/build-image.yaml
- .github/workflows/frontend-pages.yml
workflow_dispatch: workflow_dispatch:
@@ -28,10 +30,17 @@ jobs:
context: 7project/backend context: 7project/backend
secrets: inherit secrets: inherit
frontend:
name: Frontend - Build and Deploy to Cloudflare Pages (prod)
uses: ./.github/workflows/frontend-pages.yml
with:
mode: prod
secrets: inherit
deploy: deploy:
name: Helm upgrade/install (prod) name: Helm upgrade/install (prod)
runs-on: vhs runs-on: vhs
needs: [build] needs: [build, frontend]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4

180
.github/workflows/frontend-pages.yml vendored Normal file
View File

@@ -0,0 +1,180 @@
name: Frontend - Build and Deploy to Cloudflare Pages
on:
workflow_call:
inputs:
mode:
description: "Build mode: 'prod' or 'pr'"
required: true
type: string
pr_number:
description: 'PR number (required when mode=pr)'
required: false
type: string
project_name:
description: 'Cloudflare Pages project name (overrides default)'
required: false
type: string
secrets:
CLOUDFLARE_API_TOKEN:
required: true
CLOUDFLARE_ACCOUNT_ID:
required: true
outputs:
deployed_url:
description: 'URL of deployed frontend'
value: ${{ jobs.deploy.outputs.deployed_url }}
# Required repository secrets:
# CLOUDFLARE_API_TOKEN - API token with Pages:Edit (or Account:Workers Scripts:Edit) permissions
# CLOUDFLARE_ACCOUNT_ID - Your Cloudflare account ID
# Optional repository variables:
# CF_PAGES_PROJECT_NAME - Default Cloudflare Pages project name
# PROD_DOMAIN - App domain for prod releases (e.g., api.example.com or https://api.example.com)
# BACKEND_URL_PR_TEMPLATE - Template for PR backend URL. Use {PR} placeholder for PR number (e.g., https://api-pr-{PR}.example.com)
jobs:
build:
name: Build frontend
runs-on: ubuntu-latest
defaults:
run:
working-directory: 7project/frontend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: 7project/frontend/package-lock.json
- name: Install dependencies
run: npm ci
- name: Compute backend URL for Vite
id: be
env:
EVENT_NAME: ${{ github.event_name }}
PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }}
PR_TEMPLATE: ${{ vars.BACKEND_URL_PR_TEMPLATE }}
DEV_BASE_DOMAIN: ${{ secrets.BASE_DOMAIN }}
PROD_DOMAIN_VAR: ${{ vars.PROD_DOMAIN }}
PROD_DOMAIN_SECRET: ${{ secrets.PROD_DOMAIN }}
BACKEND_URL_OVERRIDE: ${{ vars.BACKEND_URL || secrets.BACKEND_URL }}
MODE: ${{ inputs.mode }}
run: |
set -euo pipefail
URL=""
# 1) Explicit override wins (from repo var or secret)
if [ -n "${BACKEND_URL_OVERRIDE:-}" ]; then
if echo "$BACKEND_URL_OVERRIDE" | grep -Eiq '^https?://'; then
URL="$BACKEND_URL_OVERRIDE"
else
URL="https://${BACKEND_URL_OVERRIDE}"
fi
else
# 2) PR-specific URL when building for PR
if [ "${MODE:-}" = "pr" ] || [ "${EVENT_NAME}" = "pull_request" ]; then
if [ -n "${PR_TEMPLATE:-}" ] && [ -n "${PR_NUMBER:-}" ] ; then
URL="${PR_TEMPLATE//\{PR\}/${PR_NUMBER}}"
elif [ -n "${DEV_BASE_DOMAIN:-}" ] && [ -n "${PR_NUMBER:-}" ]; then
URL="https://pr-${PR_NUMBER}.${DEV_BASE_DOMAIN}"
fi
fi
# 3) Fallback to PROD_DOMAIN (prefer repo var, then secret)
if [ -z "$URL" ]; then
PROD_DOMAIN="${PROD_DOMAIN_VAR:-${PROD_DOMAIN_SECRET:-}}"
if [ -n "$PROD_DOMAIN" ]; then
if echo "$PROD_DOMAIN" | grep -Eiq '^https?://'; then
URL="$PROD_DOMAIN"
else
URL="https://${PROD_DOMAIN}"
fi
fi
fi
fi
echo "Using backend URL: ${URL:-<empty>}"
echo "VITE_BACKEND_URL=${URL}" >> $GITHUB_ENV
- name: Build
run: npm run build
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: frontend-dist
path: 7project/frontend/dist
deploy:
name: Deploy to Cloudflare Pages
needs: build
runs-on: ubuntu-latest
outputs:
deployed_url: ${{ steps.out.outputs.deployed_url }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: frontend-dist
path: dist
- name: Determine project name and branch
id: pname
env:
INPUT_MODE: ${{ inputs.mode }}
INPUT_PR: ${{ inputs.pr_number }}
run: |
set -euo pipefail
# Prefer manual input, then repo variable, fallback to repo-name
INPUT_NAME='${{ inputs.project_name }}'
VAR_NAME='${{ vars.CF_PAGES_PROJECT_NAME }}'
if [ -n "$INPUT_NAME" ]; then PNAME_RAW="$INPUT_NAME";
elif [ -n "$VAR_NAME" ]; then PNAME_RAW="$VAR_NAME";
else PNAME_RAW="${GITHUB_REPOSITORY##*/}-frontend"; fi
# Normalize project name to lowercase to satisfy Cloudflare Pages naming
PNAME="${PNAME_RAW,,}"
# Determine branch for Pages
if [ "${INPUT_MODE}" = "pr" ]; then
if [ -z "${INPUT_PR}" ]; then echo "pr_number is required when mode=pr"; exit 1; fi
PBRANCH="pr-${INPUT_PR}"
else
PBRANCH="main"
fi
echo "project_name=$PNAME" >> $GITHUB_OUTPUT
echo "branch=$PBRANCH" >> $GITHUB_OUTPUT
- name: Ensure Cloudflare Pages project exists
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
PNAME: ${{ steps.pname.outputs.project_name }}
run: |
set -euo pipefail
npx wrangler pages project create "$PNAME" --production-branch=main || true
- name: Deploy using Cloudflare Wrangler
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy dist --project-name=${{ steps.pname.outputs.project_name }} --branch=${{ steps.pname.outputs.branch }}
- name: Compute deployed URL
id: out
env:
PNAME: ${{ steps.pname.outputs.project_name }}
PBRANCH: ${{ steps.pname.outputs.branch }}
run: |
set -euo pipefail
if [ "$PBRANCH" = "main" ]; then
URL="https://${PNAME}.pages.dev"
else
URL="https://${PBRANCH}.${PNAME}.pages.dev"
fi
echo "deployed_url=$URL" >> $GITHUB_OUTPUT

View File

@@ -45,11 +45,11 @@ flowchart LR
proc_cron[Task planner] --> proc_queue proc_cron[Task planner] --> proc_queue
proc_queue_worker --> ext_bank[(Bank API)] proc_queue_worker --> ext_bank[(Bank API)]
proc_queue_worker --> db proc_queue_worker --> db
client[Client/UI] --> api[API Gateway / Web Server] client[Client/UI] <--> api[API Gateway / Web Server]
api --> svc[Web API] api <--> svc[Web API]
svc --> proc_queue svc --> proc_queue
svc --> db[(Database)] svc <--> db[(Database)]
svc --> cache[(Cache)] svc <--> cache[(Cache)]
``` ```
- Components and responsibilities: What does each box do? - Components and responsibilities: What does each box do?

View File

@@ -5,4 +5,4 @@ COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY . . COPY . .
EXPOSE 8000 EXPOSE 8000
CMD alembic upgrade head && uvicorn app.app:app --host 0.0.0.0 --port 8000 CMD alembic upgrade head && uvicorn app.app:fastApi --host 0.0.0.0 --port 8000

View File

@@ -11,7 +11,7 @@ script_location = %(here)s/alembic
# Uncomment the line below if you want the files to be prepended with date and time # Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens # for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present. # sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator # defaults to the current working directory. for multiple paths, the path separator

View File

@@ -1,8 +1,8 @@
"""Init migration """add categories
Revision ID: 81f275275556 Revision ID: 63e072f09836
Revises: Revises:
Create Date: 2025-09-24 17:39:25.346690 Create Date: 2025-10-09 14:56:14.653249
""" """
from typing import Sequence, Union from typing import Sequence, Union
@@ -13,7 +13,7 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = '81f275275556' revision: str = '63e072f09836'
down_revision: Union[str, Sequence[str], None] = None down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
@@ -22,12 +22,6 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
"""Upgrade schema.""" """Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.create_table('transaction',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('amount', sa.Float(), nullable=False),
sa.Column('description', sa.String(length=255), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('user', op.create_table('user',
sa.Column('first_name', sa.String(length=100), nullable=True), sa.Column('first_name', sa.String(length=100), nullable=True),
sa.Column('last_name', sa.String(length=100), nullable=True), sa.Column('last_name', sa.String(length=100), nullable=True),
@@ -40,13 +34,38 @@ def upgrade() -> None:
sa.PrimaryKeyConstraint('id') sa.PrimaryKeyConstraint('id')
) )
op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True) op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True)
op.create_table('categories',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('description', sa.String(length=255), nullable=True),
sa.Column('user_id', fastapi_users_db_sqlalchemy.generics.GUID(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('transaction',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('amount', sa.Float(), nullable=False),
sa.Column('description', sa.String(length=255), nullable=True),
sa.Column('user_id', fastapi_users_db_sqlalchemy.generics.GUID(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('category_transaction',
sa.Column('id_category', sa.Integer(), nullable=True),
sa.Column('id_transaction', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['id_category'], ['categories.id'], ),
sa.ForeignKeyConstraint(['id_transaction'], ['transaction.id'], )
)
# ### end Alembic commands ### # ### end Alembic commands ###
def downgrade() -> None: def downgrade() -> None:
"""Downgrade schema.""" """Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.drop_table('category_transaction')
op.drop_table('transaction')
op.drop_table('categories')
op.drop_index(op.f('ix_user_email'), table_name='user') op.drop_index(op.f('ix_user_email'), table_name='user')
op.drop_table('user') op.drop_table('user')
op.drop_table('transaction')
# ### end Alembic commands ### # ### end Alembic commands ###

View File

@@ -0,0 +1,34 @@
"""update categories unique
Revision ID: 390041bd839e
Revises: 63e072f09836
Create Date: 2025-10-09 15:14:31.557686
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '390041bd839e'
down_revision: Union[str, Sequence[str], None] = '63e072f09836'
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.drop_index(op.f('name'), table_name='categories')
op.create_unique_constraint('uix_name_user_id', 'categories', ['name', 'user_id'])
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint('uix_name_user_id', 'categories', type_='unique')
op.create_index(op.f('name'), 'categories', ['name'], unique=True)
# ### end Alembic commands ###

View File

@@ -0,0 +1,48 @@
"""add user oauth
Revision ID: 7af8f296d089
Revises: 390041bd839e
Create Date: 2025-10-10 14:05:00.153376
"""
from typing import Sequence, Union
import fastapi_users_db_sqlalchemy
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '7af8f296d089'
down_revision: Union[str, Sequence[str], None] = '390041bd839e'
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.create_table('oauth_account',
sa.Column('id', fastapi_users_db_sqlalchemy.generics.GUID(), nullable=False),
sa.Column('user_id', fastapi_users_db_sqlalchemy.generics.GUID(), nullable=False),
sa.Column('oauth_name', sa.String(length=100), nullable=False),
sa.Column('access_token', sa.String(length=1024), nullable=False),
sa.Column('expires_at', sa.Integer(), nullable=True),
sa.Column('refresh_token', sa.String(length=1024), nullable=True),
sa.Column('account_id', sa.String(length=320), nullable=False),
sa.Column('account_email', sa.String(length=320), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='cascade'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_oauth_account_account_id'), 'oauth_account', ['account_id'], unique=False)
op.create_index(op.f('ix_oauth_account_oauth_name'), 'oauth_account', ['oauth_name'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_oauth_account_oauth_name'), table_name='oauth_account')
op.drop_index(op.f('ix_oauth_account_account_id'), table_name='oauth_account')
op.drop_table('oauth_account')
# ### end Alembic commands ###

View File

@@ -0,0 +1,38 @@
"""change token length
Revision ID: 5ab2e654c96e
Revises: 7af8f296d089
Create Date: 2025-10-11 21:07:41.930470
"""
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 = '5ab2e654c96e'
down_revision: Union[str, Sequence[str], None] = '7af8f296d089'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('oauth_account', 'access_token',
existing_type=mysql.VARCHAR(length=1024),
type_=sa.String(length=4096),
existing_nullable=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('oauth_account', 'access_token',
existing_type=sa.String(length=4096),
type_=mysql.VARCHAR(length=1024),
existing_nullable=False)
# ### end Alembic commands ###

View File

@@ -0,0 +1,31 @@
from fastapi import APIRouter
from app.schemas.user import UserCreate, UserRead, UserUpdate
from app.services.user_service import auth_backend, fastapi_users
router = APIRouter()
# Keep existing paths as-is under /auth/* and /users/*
router.include_router(
fastapi_users.get_auth_router(auth_backend), prefix="/auth/jwt", tags=["auth"]
)
router.include_router(
fastapi_users.get_register_router(UserRead, UserCreate),
prefix="/auth",
tags=["auth"],
)
router.include_router(
fastapi_users.get_reset_password_router(),
prefix="/auth",
tags=["auth"],
)
router.include_router(
fastapi_users.get_verify_router(UserRead),
prefix="/auth",
tags=["auth"],
)
router.include_router(
fastapi_users.get_users_router(UserRead, UserUpdate),
prefix="/users",
tags=["users"],
)

View File

@@ -0,0 +1,77 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.categories import Category
from app.schemas.category import CategoryCreate, CategoryRead
from app.services.db import get_async_session
from app.services.user_service import current_active_user
from app.models.user import User
router = APIRouter(prefix="/categories", tags=["categories"])
@router.post("/create", response_model=CategoryRead, status_code=status.HTTP_201_CREATED)
async def create_category(
payload: CategoryCreate,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
# Enforce per-user unique name via query to provide 409 feedback
res = await session.execute(
select(Category).where(Category.user_id == user.id, Category.name == payload.name)
)
existing = res.scalar_one_or_none()
if existing:
raise HTTPException(status_code=409, detail="Category with this name already exists")
category = Category(name=payload.name, description=payload.description, user_id=user.id)
session.add(category)
await session.commit()
await session.refresh(category)
return category
@router.get("/", response_model=List[CategoryRead])
async def list_categories(
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
res = await session.execute(select(Category).where(Category.user_id == user.id))
return list(res.scalars())
@router.get("/{category_id}", response_model=CategoryRead)
async def get_category(
category_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
res = await session.execute(
select(Category).where(Category.id == category_id, Category.user_id == user.id)
)
category = res.scalar_one_or_none()
if not category:
raise HTTPException(status_code=404, detail="Category not found")
return category
@router.delete("/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_category(
category_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
res = await session.execute(
select(Category.id).where(Category.id == category_id, Category.user_id == user.id)
)
if res.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="Category not found")
await session.execute(
delete(Category).where(Category.id == category_id, Category.user_id == user.id)
)
await session.commit()
return None

View File

@@ -0,0 +1,219 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.transaction import Transaction
from app.models.categories import Category
from app.schemas.transaction import (
TransactionCreate,
TransactionRead,
TransactionUpdate,
)
from app.services.db import get_async_session
from app.services.user_service import current_active_user
from app.models.user import User
router = APIRouter(prefix="/transactions", tags=["transactions"])
def _to_read_model(tx: Transaction) -> TransactionRead:
return TransactionRead(
id=tx.id,
amount=tx.amount,
description=tx.description,
category_ids=[c.id for c in (tx.categories or [])],
)
@router.post("/create", response_model=TransactionRead, status_code=status.HTTP_201_CREATED)
async def create_transaction(
payload: TransactionCreate,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
tx = Transaction(amount=payload.amount, description=payload.description, user_id=user.id)
# Attach categories if provided (and owned by user)
if payload.category_ids:
res = await session.execute(
select(Category).where(
Category.user_id == user.id, Category.id.in_(payload.category_ids)
)
)
categories = list(res.scalars())
if len(categories) != len(set(payload.category_ids)):
raise HTTPException(
status_code=400,
detail="Duplicate category IDs provided or one or more categories not found"
)
tx.categories = categories
session.add(tx)
await session.commit()
await session.refresh(tx)
# Ensure categories are loaded
await session.refresh(tx, attribute_names=["categories"])
return _to_read_model(tx)
@router.get("/", response_model=List[TransactionRead])
async def list_transactions(
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
res = await session.execute(
select(Transaction).where(Transaction.user_id == user.id).order_by(Transaction.id)
)
txs = list(res.scalars())
# Eagerly load categories for each transaction
for tx in txs:
await session.refresh(tx, attribute_names=["categories"])
return [_to_read_model(tx) for tx in txs]
@router.get("/{transaction_id}", response_model=TransactionRead)
async def get_transaction(
transaction_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
res = await session.execute(
select(Transaction).where(
Transaction.id == transaction_id, Transaction.user_id == user.id
)
)
tx: Optional[Transaction] = res.scalar_one_or_none()
if not tx:
raise HTTPException(status_code=404, detail="Transaction not found")
await session.refresh(tx, attribute_names=["categories"])
return _to_read_model(tx)
@router.patch("/{transaction_id}/edit", response_model=TransactionRead)
async def update_transaction(
transaction_id: int,
payload: TransactionUpdate,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
res = await session.execute(
select(Transaction).where(
Transaction.id == transaction_id, Transaction.user_id == user.id
)
)
tx: Optional[Transaction] = res.scalar_one_or_none()
if not tx:
raise HTTPException(status_code=404, detail="Transaction not found")
if payload.amount is not None:
tx.amount = payload.amount
if payload.description is not None:
tx.description = payload.description
if payload.category_ids is not None:
# Preload categories to avoid async lazy-load during assignment
await session.refresh(tx, attribute_names=["categories"])
if payload.category_ids:
# Check for duplicate category IDs in the payload
if len(payload.category_ids) != len(set(payload.category_ids)):
raise HTTPException(status_code=400, detail="Duplicate category IDs in payload")
res = await session.execute(
select(Category).where(
Category.user_id == user.id, Category.id.in_(payload.category_ids)
)
)
categories = list(res.scalars())
if len(categories) != len(payload.category_ids):
raise HTTPException(status_code=400, detail="One or more categories not found")
tx.categories = categories
else:
tx.categories = []
await session.commit()
await session.refresh(tx, attribute_names=["categories"])
return _to_read_model(tx)
@router.delete("/{transaction_id}/delete", status_code=status.HTTP_204_NO_CONTENT)
async def delete_transaction(
transaction_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
res = await session.execute(
select(Transaction).where(
Transaction.id == transaction_id, Transaction.user_id == user.id
)
)
tx = res.scalar_one_or_none()
if not tx:
raise HTTPException(status_code=404, detail="Transaction not found")
await session.delete(tx)
await session.commit()
return None
@router.post("/{transaction_id}/categories/{category_id}", response_model=TransactionRead)
async def assign_category(
transaction_id: int,
category_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
# Load transaction and category ensuring ownership
res_tx = await session.execute(
select(Transaction).where(
Transaction.id == transaction_id, Transaction.user_id == user.id
)
)
tx: Optional[Transaction] = res_tx.scalar_one_or_none()
if not tx:
raise HTTPException(status_code=404, detail="Transaction not found")
res_cat = await session.execute(
select(Category).where(Category.id == category_id, Category.user_id == user.id)
)
cat: Optional[Category] = res_cat.scalar_one_or_none()
if not cat:
raise HTTPException(status_code=404, detail="Category not found")
await session.refresh(tx, attribute_names=["categories"])
if cat not in tx.categories:
tx.categories.append(cat)
await session.commit()
await session.refresh(tx, attribute_names=["categories"])
return _to_read_model(tx)
@router.delete("/{transaction_id}/categories/{category_id}", response_model=TransactionRead)
async def unassign_category(
transaction_id: int,
category_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
res_tx = await session.execute(
select(Transaction).where(
Transaction.id == transaction_id, Transaction.user_id == user.id
)
)
tx: Optional[Transaction] = res_tx.scalar_one_or_none()
if not tx:
raise HTTPException(status_code=404, detail="Transaction not found")
res_cat = await session.execute(
select(Category).where(Category.id == category_id, Category.user_id == user.id)
)
cat: Optional[Category] = res_cat.scalar_one_or_none()
if not cat:
raise HTTPException(status_code=404, detail="Category not found")
await session.refresh(tx, attribute_names=["categories"])
if cat in tx.categories:
tx.categories.remove(cat)
await session.commit()
await session.refresh(tx, attribute_names=["categories"])
return _to_read_model(tx)

View File

@@ -3,13 +3,16 @@ from fastapi.middleware.cors import CORSMiddleware
from app.models.user import User from app.models.user import User
from app.schemas.user import UserCreate, UserRead, UserUpdate from app.services.user_service import current_active_verified_user
from app.services.user_service import auth_backend, current_active_verified_user, fastapi_users from app.api.auth import router as auth_router
from app.api.categories import router as categories_router
from app.api.transactions import router as transactions_router
from app.services.user_service import auth_backend, current_active_verified_user, fastapi_users, get_oauth_provider
app = FastAPI() fastApi = FastAPI()
# CORS for frontend dev server # CORS for frontend dev server
app.add_middleware( fastApi.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=[ allow_origins=[
"http://localhost:5173", "http://localhost:5173",
@@ -20,37 +23,39 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
app.include_router( fastApi.include_router(auth_router)
fastapi_users.get_auth_router(auth_backend), prefix="/auth/jwt", tags=["auth"] fastApi.include_router(categories_router)
) fastApi.include_router(transactions_router)
app.include_router(
fastapi_users.get_register_router(UserRead, UserCreate), fastApi.include_router(
prefix="/auth", fastapi_users.get_oauth_router(
get_oauth_provider("MojeID"),
auth_backend,
"SECRET",
associate_by_email=True,
),
prefix="/auth/mojeid",
tags=["auth"], tags=["auth"],
) )
app.include_router(
fastapi_users.get_reset_password_router(), fastApi.include_router(
prefix="/auth", fastapi_users.get_oauth_router(
get_oauth_provider("BankID"),
auth_backend,
"SECRET",
associate_by_email=True,
),
prefix="/auth/bankid",
tags=["auth"], tags=["auth"],
) )
app.include_router(
fastapi_users.get_verify_router(UserRead),
prefix="/auth",
tags=["auth"],
)
app.include_router(
fastapi_users.get_users_router(UserRead, UserUpdate),
prefix="/users",
tags=["users"],
)
# Liveness/root endpoint # Liveness/root endpoint
@app.get("/", include_in_schema=False) @fastApi.get("/", include_in_schema=False)
async def root(): async def root():
return {"status": "ok"} return {"status": "ok"}
@app.get("/authenticated-route") @fastApi.get("/authenticated-route")
async def authenticated_route(user: User = Depends(current_active_verified_user)): async def authenticated_route(user: User = Depends(current_active_verified_user)):
return {"message": f"Hello {user.email}!"} return {"message": f"Hello {user.email}!"}

View File

@@ -17,6 +17,7 @@ if not DATABASE_URL:
# Load all models to register them # Load all models to register them
from app.models.user import User from app.models.user import User
from app.models.transaction import Transaction from app.models.transaction import Transaction
from app.models.categories import Category
ssl_enabled = os.getenv("MARIADB_HOST", "localhost") != "localhost" ssl_enabled = os.getenv("MARIADB_HOST", "localhost") != "localhost"
connect_args = {"ssl": {"ssl": True}} if ssl_enabled else {} connect_args = {"ssl": {"ssl": True}} if ssl_enabled else {}

View File

@@ -0,0 +1,25 @@
from fastapi_users_db_sqlalchemy import GUID
from sqlalchemy import Column, Integer, String, ForeignKey, Table, UniqueConstraint
from sqlalchemy.orm import relationship
from app.core.base import Base
association_table = Table(
"category_transaction",
Base.metadata,
Column("id_category", Integer, ForeignKey("categories.id")),
Column("id_transaction", Integer, ForeignKey("transaction.id"))
)
class Category(Base):
__tablename__ = "categories"
__table_args__ = (
UniqueConstraint("name", "user_id", name="uix_name_user_id"),
)
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(length=100), nullable=False)
description = Column(String(length=255), nullable=True)
user_id = Column(GUID, ForeignKey("user.id"), nullable=False)
user = relationship("User", back_populates="categories")
transactions = relationship("Transaction", secondary=association_table, back_populates="categories")

View File

@@ -1,9 +1,17 @@
from sqlalchemy import Column, Integer, String, Float from fastapi_users_db_sqlalchemy import GUID
from sqlalchemy import Column, Integer, String, Float, ForeignKey
from sqlalchemy.orm import relationship
from app.core.base import Base from app.core.base import Base
from app.models.categories import association_table
class Transaction(Base): class Transaction(Base):
__tablename__ = "transaction" __tablename__ = "transaction"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
amount = Column(Float, nullable=False) amount = Column(Float, nullable=False)
description = Column(String(length=255), nullable=True) description = Column(String(length=255), nullable=True)
user_id = Column(GUID, ForeignKey("user.id"), nullable=False)
# Relationship
user = relationship("User", back_populates="transactions")
categories = relationship("Category", secondary=association_table, back_populates="transactions")

View File

@@ -1,7 +1,19 @@
from sqlalchemy import Column, String from sqlalchemy import Column, String
from fastapi_users.db import SQLAlchemyBaseUserTableUUID from sqlalchemy.orm import relationship, mapped_column, Mapped
from fastapi_users.db import SQLAlchemyBaseUserTableUUID, SQLAlchemyBaseOAuthAccountTableUUID
from app.core.base import Base from app.core.base import Base
class OAuthAccount(SQLAlchemyBaseOAuthAccountTableUUID, Base):
# BankID token is longer than default
access_token: Mapped[str] = mapped_column(String(length=4096), nullable=False)
class User(SQLAlchemyBaseUserTableUUID, Base): class User(SQLAlchemyBaseUserTableUUID, Base):
first_name = Column(String(length=100), nullable=True) first_name = Column(String(length=100), nullable=True)
last_name = Column(String(length=100), nullable=True) last_name = Column(String(length=100), nullable=True)
oauth_accounts = relationship("OAuthAccount", lazy="joined")
# Relationship
transactions = relationship("Transaction", back_populates="user")
categories = relationship("Category", back_populates="user")

View File

View File

@@ -0,0 +1,50 @@
import secrets
from typing import Optional, Literal
from httpx_oauth.oauth2 import T
from app.oauth.custom_openid import CustomOpenID
class BankID(CustomOpenID):
def __init__(self, client_id: str, client_secret: str):
super().__init__(
client_id,
client_secret,
"https://oidc.sandbox.bankid.cz/.well-known/openid-configuration",
"BankID",
base_scopes=["openid", "profile.email", "profile.name"],
)
async def get_user_info(self, token: str) -> dict:
info = await self.get_profile(token)
return {
"first_name": info.get("given_name"),
"last_name": info.get("family_name"),
}
async def get_authorization_url(
self,
redirect_uri: str,
state: Optional[str] = None,
scope: Optional[list[str]] = None,
code_challenge: Optional[str] = None,
code_challenge_method: Optional[Literal["plain", "S256"]] = None,
extras_params: Optional[T] = None,
) -> str:
if extras_params is None:
extras_params = {}
# BankID requires random nonce parameter for security
# https://developer.bankid.cz/docs/security_sep
extras_params["nonce"] = secrets.token_urlsafe()
return await super().get_authorization_url(
redirect_uri,
state,
scope,
code_challenge,
code_challenge_method,
extras_params,
)

View File

@@ -0,0 +1,6 @@
from httpx_oauth.clients.openid import OpenID
class CustomOpenID(OpenID):
async def get_user_info(self, token: str) -> dict:
raise NotImplementedError()

View File

@@ -0,0 +1,56 @@
import json
from typing import Optional, Literal, Any
from httpx_oauth.oauth2 import T
from app.oauth.custom_openid import CustomOpenID
class MojeIDOAuth(CustomOpenID):
def __init__(self, client_id: str, client_secret: str):
super().__init__(
client_id,
client_secret,
"https://mojeid.regtest.nic.cz/.well-known/openid-configuration/",
"MojeID",
base_scopes=["openid", "email", "profile"],
)
async def get_user_info(self, token: str) -> Optional[Any]:
info = await self.get_profile(token)
return {
"first_name": info.get("given_name"),
"last_name": info.get("family_name"),
}
async def get_authorization_url(
self,
redirect_uri: str,
state: Optional[str] = None,
scope: Optional[list[str]] = None,
code_challenge: Optional[str] = None,
code_challenge_method: Optional[Literal["plain", "S256"]] = None,
extras_params: Optional[T] = None,
) -> str:
required_fields = {
'id_token': {
'name': {'essential': True},
'given_name': {'essential': True},
'family_name': {'essential': True},
'email': {'essential': True},
'mojeid_valid': {'essential': True},
}}
if extras_params is None:
extras_params = {}
extras_params["claims"] = json.dumps(required_fields)
return await super().get_authorization_url(
redirect_uri,
state,
scope,
code_challenge,
code_challenge_method,
extras_params,
)

View File

@@ -0,0 +1,16 @@
from typing import Optional
from pydantic import BaseModel, ConfigDict
class CategoryBase(BaseModel):
name: str
description: Optional[str] = None
class CategoryCreate(CategoryBase):
pass
class CategoryRead(CategoryBase):
id: int
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,21 @@
from typing import List, Optional
from pydantic import BaseModel, Field, ConfigDict
class TransactionBase(BaseModel):
amount: float = Field(..., gt=-1e18, lt=1e18)
description: Optional[str] = None
class TransactionCreate(TransactionBase):
category_ids: Optional[List[int]] = None
class TransactionUpdate(BaseModel):
amount: Optional[float] = Field(None, gt=-1e18, lt=1e18)
description: Optional[str] = None
category_ids: Optional[List[int]] = None
class TransactionRead(TransactionBase):
id: int
category_ids: List[int] = []
model_config = ConfigDict(from_attributes=True)

View File

@@ -4,13 +4,13 @@ from fastapi_users import schemas
class UserRead(schemas.BaseUser[uuid.UUID]): class UserRead(schemas.BaseUser[uuid.UUID]):
first_name: Optional[str] = None first_name: Optional[str] = None
surname: Optional[str] = None last_name: Optional[str] = None
class UserCreate(schemas.BaseUserCreate): class UserCreate(schemas.BaseUserCreate):
first_name: Optional[str] = None first_name: Optional[str] = None
surname: Optional[str] = None last_name: Optional[str] = None
class UserUpdate(schemas.BaseUserUpdate): class UserUpdate(schemas.BaseUserUpdate):
first_name: Optional[str] = None first_name: Optional[str] = None
surname: Optional[str] = None last_name: Optional[str] = None

View File

@@ -4,11 +4,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from fastapi_users.db import SQLAlchemyUserDatabase from fastapi_users.db import SQLAlchemyUserDatabase
from ..core.db import async_session_maker from ..core.db import async_session_maker
from ..models.user import User from ..models.user import User, OAuthAccount
async def get_async_session() -> AsyncGenerator[AsyncSession, None]: async def get_async_session() -> AsyncGenerator[AsyncSession, None]:
async with async_session_maker() as session: async with async_session_maker() as session:
yield session yield session
async def get_user_db(session: AsyncSession = Depends(get_async_session)): async def get_user_db(session: AsyncSession = Depends(get_async_session)):
yield SQLAlchemyUserDatabase(session, User) yield SQLAlchemyUserDatabase(session, User, OAuthAccount)

View File

@@ -3,26 +3,66 @@ import uuid
from typing import Optional from typing import Optional
from fastapi import Depends, Request from fastapi import Depends, Request
from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin, models
from fastapi_users.authentication import ( from fastapi_users.authentication import (
AuthenticationBackend, AuthenticationBackend,
BearerTransport, BearerTransport,
) )
from fastapi_users.authentication.strategy.jwt import JWTStrategy from fastapi_users.authentication.strategy.jwt import JWTStrategy
from fastapi_users.db import SQLAlchemyUserDatabase from fastapi_users.db import SQLAlchemyUserDatabase
from httpx_oauth.oauth2 import BaseOAuth2
from app.models.user import User from app.models.user import User
from app.oauth.bank_id import BankID
from app.oauth.custom_openid import CustomOpenID
from app.oauth.moje_id import MojeIDOAuth
from app.services.db import get_user_db from app.services.db import get_user_db
from app.core.queue import enqueue_email from app.core.queue import enqueue_email
SECRET = os.getenv("SECRET", "CHANGE_ME_SECRET") SECRET = os.getenv("SECRET", "CHANGE_ME_SECRET")
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:5173") FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:5173")
BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8000") BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8000")
providers = {
"MojeID": MojeIDOAuth(
os.getenv("MOJEID_CLIENT_ID", "CHANGE_ME_CLIENT_ID"),
os.getenv("MOJEID_CLIENT_SECRET", "CHANGE_ME_CLIENT_SECRET"),
),
"BankID": BankID(
os.getenv("BANKID_CLIENT_ID", "CHANGE_ME_CLIENT_ID"),
os.getenv("BANKID_CLIENT_SECRET", "CHANGE_ME_CLIENT_SECRET"),
)
}
def get_oauth_provider(name: str) -> Optional[BaseOAuth2]:
if name not in providers:
return None
return providers[name]
class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
reset_password_token_secret = SECRET reset_password_token_secret = SECRET
verification_token_secret = SECRET verification_token_secret = SECRET
async def oauth_callback(self: "BaseUserManager[models.UOAP, models.ID]", oauth_name: str, access_token: str,
account_id: str, account_email: str, expires_at: Optional[int] = None,
refresh_token: Optional[str] = None, request: Optional[Request] = None, *,
associate_by_email: bool = False, is_verified_by_default: bool = False) -> models.UOAP:
user = await super().oauth_callback(oauth_name, access_token, account_id, account_email, expires_at,
refresh_token, request, associate_by_email=associate_by_email,
is_verified_by_default=is_verified_by_default)
# set additional user info from the OAuth provider
provider = get_oauth_provider(oauth_name)
if provider is not None and isinstance(provider, CustomOpenID):
update_dict = await provider.get_user_info(access_token)
await self.user_db.update(user, update_dict)
return user
async def on_after_register(self, user: User, request: Optional[Request] = None): async def on_after_register(self, user: User, request: Optional[Request] = None):
await self.request_verify(user, request) await self.request_verify(user, request)
@@ -52,14 +92,18 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
print("[Email Fallback] Subject:", subject) print("[Email Fallback] Subject:", subject)
print("[Email Fallback] Body:\n", body) print("[Email Fallback] Body:\n", body)
async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db)): async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db)):
yield UserManager(user_db) yield UserManager(user_db)
bearer_transport = BearerTransport(tokenUrl="auth/jwt/login") bearer_transport = BearerTransport(tokenUrl="auth/jwt/login")
def get_jwt_strategy() -> JWTStrategy: def get_jwt_strategy() -> JWTStrategy:
return JWTStrategy(secret=SECRET, lifetime_seconds=3600) return JWTStrategy(secret=SECRET, lifetime_seconds=3600)
auth_backend = AuthenticationBackend( auth_backend = AuthenticationBackend(
name="jwt", name="jwt",
transport=bearer_transport, transport=bearer_transport,
@@ -70,4 +114,3 @@ fastapi_users = FastAPIUsers[User, uuid.UUID](get_user_manager, [auth_backend])
current_active_user = fastapi_users.current_user(active=True) current_active_user = fastapi_users.current_user(active=True)
current_active_verified_user = fastapi_users.current_user(active=True, verified=True) current_active_verified_user = fastapi_users.current_user(active=True, verified=True)

View File

@@ -11,6 +11,7 @@ asyncmy==0.2.9
bcrypt==4.3.0 bcrypt==4.3.0
billiard==4.2.2 billiard==4.2.2
celery==5.5.3 celery==5.5.3
certifi==2025.10.5
cffi==2.0.0 cffi==2.0.0
click==8.1.8 click==8.1.8
click-didyoumean==0.3.1 click-didyoumean==0.3.1
@@ -25,7 +26,10 @@ fastapi-users==14.0.1
fastapi-users-db-sqlalchemy==7.0.0 fastapi-users-db-sqlalchemy==7.0.0
greenlet==3.2.4 greenlet==3.2.4
h11==0.16.0 h11==0.16.0
httpcore==1.0.9
httptools==0.6.4 httptools==0.6.4
httpx==0.28.1
httpx-oauth==0.16.1
idna==3.10 idna==3.10
kombu==5.5.4 kombu==5.5.4
makefun==1.16.0 makefun==1.16.0

View File

@@ -25,7 +25,7 @@ spec:
- containerPort: {{ .Values.app.port }} - containerPort: {{ .Values.app.port }}
env: env:
- name: MARIADB_HOST - name: MARIADB_HOST
value: {{ printf "%s.%s.svc.cluster.local" .Values.mariadb.mariaDbRef.name .Values.mariadb.mariaDbRef.namespace | quote }} value: "mariadb-repl-maxscale-internal.mariadb-operator.svc.cluster.local"
- name: MARIADB_PORT - name: MARIADB_PORT
value: '3306' value: '3306'
- name: MARIADB_DB - name: MARIADB_DB

View File

@@ -29,6 +29,7 @@ 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"
service: service:
port: 80 port: 80

81
7project/checklist.md Normal file
View File

@@ -0,0 +1,81 @@
# Project Evaluation Checklist
The group earn points by completing items from the categories below.
You are not expected to complete all items.
Focus on areas that align with your project goals and interests.
The core deliverables are required.
This means that you must get at least 2 points for each item in this category.
| **Category** | **Item** | **Max Points** | **Points** |
| -------------------------------- | --------------------------------------- | -------------- | ---------------- |
| **Core Deliverables (Required)** | | | |
| Codebase & Organization | Well-organized project structure | 5 | |
| | Clean, readable code | 5 | |
| | Use planning tool (e.g., GitHub issues) | 5 | |
| | Proper version control usage | 5 | |
| | Complete source code | 5 | |
| Documentation | Comprehensive reproducibility report | 10 | |
| | Updated design document | 5 | |
| | Clear build/deployment instructions | 5 | |
| | Troubleshooting guide | 5 | |
| | Completed self-assessment table | 5 | |
| | Hour sheets for all members | 5 | |
| Presentation Video | Project demonstration | 5 | |
| | Code walk-through | 5 | |
| | Deployment showcase | 5 | |
| **Technical Implementation** | | | |
| Application Functionality | Basic functionality works | 10 | |
| | Advanced features implemented | 10 | |
| | Error handling & robustness | 10 | |
| | User-friendly interface | 5 | |
| Backend & Architecture | Stateless web server | 5 | |
| | Stateful application | 10 | |
| | Database integration | 10 | |
| | API design | 5 | |
| | Microservices architecture | 10 | |
| Cloud Integration | Basic cloud deployment | 10 | |
| | Cloud APIs usage | 10 | |
| | Serverless components | 10 | |
| | Advanced cloud services | 5 | |
| **DevOps & Deployment** | | | |
| Containerization | Basic Dockerfile | 5 | |
| | Optimized Dockerfile | 5 | |
| | Docker Compose | 5 | |
| | Persistent storage | 5 | |
| Deployment & Scaling | Manual deployment | 5 | |
| | Automated deployment | 5 | |
| | Multiple replicas | 5 | |
| | Kubernetes deployment | 10 | |
| **Quality Assurance** | | | |
| Testing | Unit tests | 5 | |
| | Integration tests | 5 | |
| | End-to-end tests | 5 | |
| | Performance testing | 5 | |
| Monitoring & Operations | Health checks | 5 | |
| | Logging | 5 | |
| | Metrics/Monitoring | 5 | |
| Security | HTTPS/TLS | 5 | |
| | Authentication | 5 | |
| | Authorization | 5 | |
| **Innovation & Excellence** | | | |
| Advanced Features and | AI/ML Integration | 10 | |
| Technical Excellence | Real-time features | 10 | |
| | Creative problem solving | 10 | |
| | Performance optimization | 5 | |
| | Exceptional user experience | 5 | |
| **Total** | | **255** | **[Your Total]** |
## Grading Scale
- **Minimum Required: 100 points**
- **Maximum: 200+ points**
| Grade | Points |
| ----- | -------- |
| A | 180-200+ |
| B | 160-179 |
| C | 140-159 |
| D | 120-139 |
| E | 100-119 |
| F | 0-99 |

View File

@@ -15,7 +15,7 @@ services:
volumes: volumes:
- redis_data:/data - redis_data:/data
rabbitmq: rabbitmq:
image: bitnami/rabbitmq:3.13.3-debian-12-r0 image: bitnamilegacy/rabbitmq:3.13.3-debian-12-r0
network_mode: host network_mode: host
ports: ports:
- "5672:5672" - "5672:5672"

View File

@@ -8,4 +8,8 @@ fi
cd backend || { echo "Directory 'backend' does not exist"; exit 1; } cd backend || { echo "Directory 'backend' does not exist"; exit 1; }
alembic revision --autogenerate -m "$1" alembic revision --autogenerate -m "$1"
git add alembic/versions/* git add alembic/versions/*
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${YELLOW}Don't forget to check imports in the new migration file!${NC}"
cd - || exit cd - || exit

24
7project/frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3426
7project/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.1.1",
"react-dom": "^19.1.1"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@types/node": "^24.6.0",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.4",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.45.0",
"vite": "^7.1.7"
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@@ -0,0 +1,39 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'
import { BACKEND_URL } from './config'
function App() {
const [count, setCount] = useState(0)
return (
<>
<div>
<a href="https://vite.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
<p style={{ fontSize: 12, color: '#888' }}>
Backend URL: <code>{BACKEND_URL || '(not configured)'}</code>
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
)
}
export default App

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

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

View File

@@ -0,0 +1,68 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

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

View File

@@ -0,0 +1,53 @@
# 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-08
- Attendees: Dejan Ribarovski, Lukas Trkan
- Notetaker: Dejan Ribarovski
## Progress Update (Before Meeting)
Summary of what has been accomplished since the last meeting in the following categories.
## Action Items from Last Week (During Meeting)
- [x] start coding the app logic
- [x] start writing the report so it matches the actual progress
- [x] redo the system diagram so it includes a response flow
### Coding
Implemented initial functioning version of the app, added OAuth with BankId and MojeID,
added database snapshots.
### Documentation
report.md is up to date
## Questions and Topics for Discussion (Before Meeting)
Prepare 3-5 questions and topics you want to discuss with your mentor.
1. What other functionality should be added to the app
2. Priority for the next week (Testing maybe?)
3. Question 3
## Discussion Notes (During Meeting)
## Action Items for Next Week (During Meeting)
Last 3 minutes of the meeting, summarize action items.
- [ ] OAuth
- [ ] CI/CD fix
- [ ] Database local (multiple bank accounts)
- [ ] Add tests and set up github pipeline
- [ ] Frontend imporvment - user experience
- [ ] make the report more clear
---

View File

@@ -0,0 +1,54 @@
# Weekly Meeting Notes
- Group 8 - Personal finance tracker
- Mentor: Jaychander
Keep all meeting notes in the `meetings.md` file in your project folder.
Just copy the template below for each weekly meeting and fill in the details.
## Administrative Info
- Date: 2025-10-08
- Attendees: Dejan Ribarovski, Lukas Trkan
- Notetaker: Dejan Ribarovski
## Progress Update (Before Meeting)
Summary of what has been accomplished since the last meeting in the following categories.
### Coding
Lukas has implemented the template source directories, source files and config files necessary for deployment
- docker compose for database, redis cache and rabbit MQ
- tofu
- backend template
- frontend template
- charts templates
### Documentation
- Created GitHub issues for the next steps
- Added this document + checklist and report
## Questions and Topics for Discussion (Before Meeting)
Prepare 3-5 questions and topics you want to discuss with your mentor.
1. Anything we should add structure-wise?
2. Anything you would like us to prioritize until next week?
## Discussion Notes (During Meeting)
- start working on the report
- start coding the actual code
- write problems solved
- redo the system diagram - see the response as well
- create a meetings folder wih seperate meetings files
## Action Items for Next Week (During Meeting)
Last 3 minutes of the meeting, summarize action items.
- [ ] start coding the app logic
- [ ] start writing the report so it matches the actual progress
- [ ] redo the system diagram so it includes a response flow
---

View File

@@ -0,0 +1,41 @@
# Weekly Meeting Notes
- Group X - Project Title
- Mentor: Mentor Name
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-09-19
- Attendees: Name1, Name2, Name3
- Notetaker: Name1
## Progress Update (Before Meeting)
Summary of what has been accomplished since the last meeting in the following categories.
### Coding
### Documentation
## Questions and Topics for Discussion (Before Meeting)
Prepare 3-5 questions and topics you want to discuss with your mentor.
1. Question 1
2. Question 2
3. Question 3
## Discussion Notes (During Meeting)
## Action Items for Next Week (During Meeting)
Last 3 minutes of the meeting, summarize action items.
- [ ] Action Item 1
- [ ] Action Item 2
- [ ] Action Item 3
---

394
7project/report.md Normal file
View File

@@ -0,0 +1,394 @@
# Personal finance tracker
> **Instructions**:
> This template provides the structure for your project report.
> Replace the placeholder text with your actual content.
> Remove instructions that are not relevant for your project, but leave the headings along with a (NA) label.
## Project Overview
**Project Name**: Personal Finance Tracker
**Group Members**:
- 289229, Lukáš Trkan, lukastrkan
- 289258, Dejan Ribarovski, derib2613, ribardej
**Brief Description**:
Our application is a finance tracker, so a person can easily track his cash flow
through multiple bank accounts. Person can label transactions with custom categories
and later filter by them.
## Architecture Overview
Our system is a fullstack web application composed of a React frontend, a FastAPI backend, a PostgreSQL database, and asynchronous background workers powered by Celery with RabbitMQ. Redis is available for caching/kv and may be used by Celery as a result backend. The backend exposes REST endpoints for authentication (email/password and OAuth), users, categories, and transactions. A thin controller layer (FastAPI routers) lives under app/api. Infrastructure for Kubernetes is provided via OpenTofu (Terraformcompatible) modules and the application is packaged via a Helm chart.
### High-Level Architecture
```mermaid
flowchart LR
proc_queue[Message Queue] --> proc_queue_worker[Worker Service]
proc_queue_worker --> ext_mail[(Email Service)]
proc_cron[Task planner] --> proc_queue
proc_queue_worker --> ext_bank[(Bank API)]
proc_queue_worker --> db
client[Client/Frontend] <--> svc[Backend API]
svc --> proc_queue
svc <--> db[(Database)]
svc <--> cache[(Cache)]
```
### 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.
- Backend API (backend/app): FastAPI app with routers under app/api for auth, categories, and transactions. Uses FastAPI Users for auth (JWT + OAuth), SQLAlchemy ORM, and Pydantic v2 schemas.
- Worker service (backend/app/workers): Celery worker handling asynchronous tasks (e.g., sending verification emails, future background processing).
- Database (PostgreSQL): Persists users, categories, transactions; schema managed by Alembic migrations.
- Message Queue (RabbitMQ): Transports background jobs from the API to the worker.
- Cache/Result Store (Redis): Available for caching or Celery result backend.
- Infrastructure as Code (tofu/): OpenTofu modules provisioning cluster services (RabbitMQ, Redis, Argo CD, cert-manager, Cloudflare tunnel, etc.).
- Deployment Chart (charts/myapp-chart/): Helm chart to deploy the application to Kubernetes.
### Technologies Used
- Backend: Python, FastAPI, FastAPI Users, SQLAlchemy, Pydantic, Alembic, Celery
- Frontend: React, TypeScript, Vite
- Database: PostgreSQL
- Messaging: RabbitMQ
- Cache: Redis
- Containerization/Orchestration: Docker, Docker Compose (dev), Kubernetes, Helm
- IaC/Platform: OpenTofu (Terraform), Argo CD, cert-manager, MetalLB, Cloudflare Tunnel, Prometheus
## Prerequisites
### System Requirements
- Operating System: Linux, macOS, or Windows
- Minimum RAM: 4 GB (8 GB recommended for running backend, frontend, and database together)
- Storage: 2 GB free (Docker images may require additional space)
### Required Software
- Docker Desktop or Docker Engine 24+
- Docker Compose v2+
- Node.js 20+ and npm 10+ (for local frontend dev/build)
- Python 3.12+ (for local backend dev outside Docker)
- PostgreSQL 15+ (optional if running DB outside Docker)
- Helm 3.12+ and kubectl 1.29+ (for Kubernetes deployment)
- OpenTofu 1.7+ (for infrastructure provisioning)
### Environment Variables (common)
- Backend: SECRET, FRONTEND_URL, BACKEND_URL, DATABASE_URL, RABBITMQ_URL, REDIS_URL
- OAuth vars (Backend): MOJEID_CLIENT_ID/SECRET, BANKID_CLIENT_ID/SECRET (optional)
- Frontend: VITE_BACKEND_URL
### Dependencies (key libraries)
I am not sure what is meant by "key libraries"
Backend: FastAPI, fastapi-users, SQLAlchemy, pydantic v2, Alembic, Celery
Frontend: React, TypeScript, Vite
Services: PostgreSQL, RabbitMQ, Redis
## Build Instructions
You can run the project with Docker Compose (recommended for local development) or run services manually.
### 1) Clone the Repository
```bash
git clone https://github.com/dat515-2025/Group-8.git
cd 7project
```
### 2) Install dependencies
Backend
```bash
# In 7project/backend
python3.12 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
```
Frontend
```bash
# In 7project/frontend
npm install
```
### 3) Manual Local Run
Backend
```bash
# From the 7project/ directory
docker compose up --build
# This starts: PostgreSQL, RabbitMQ/Redis (if defined)
# Set environment variables (or create .env file)
export SECRET=CHANGE_ME_SECRET
export BACKEND_URL=http://127.0.0.1:8000
export FRONTEND_URL=http://localhost:5173
export DATABASE_URL=postgresql+asyncpg://user:password@127.0.0.1:5432/app
export RABBITMQ_URL=amqp://guest:guest@127.0.0.1:5672/
export REDIS_URL=redis://127.0.0.1:6379/0
# Apply DB migrations (Alembic)
# From 7project/backend
alembic upgrade head
# Run API
uvicorn app.app:fastApi --reload --host 0.0.0.0 --port 8000
# Run Celery worker (optional, for emails/background tasks)
celery -A app.celery_app.celery_app worker -l info
```
Frontend
```bash
# Configure backend URL for dev
echo 'VITE_BACKEND_URL=http://127.0.0.1:8000' > .env
npm run dev
# Open http://localhost:5173
```
- Backend default: http://127.0.0.1:8000 (OpenAPI at /docs)
- Frontend default: http://localhost:5173
If needed, adjust compose services/ports in compose.yml.
## Deployment Instructions
### Local (Docker Compose)
Described in the previous section (Manual Local Run)
### Kubernetes (via OpenTofu + Helm)
1) Provision platform services (RabbitMQ/Redis/ingress/tunnel/etc.) with OpenTofu
```bash
cd tofu
# copy and edit variables
cp terraform.tfvars.example terraform.tfvars
# authenticate to your cluster/cloud as needed, then:
tofu init
tofu plan
tofu apply
```
2) Deploy the app using Helm
```bash
# Set the namespace
kubectl create namespace myapp || true
# Install/upgrade the chart with required values
helm upgrade --install myapp charts/myapp-chart \
-n myapp \
-f charts/myapp-chart/values.yaml \
--set image.backend.repository=myorg/myapp-backend \
--set image.backend.tag=latest \
--set env.BACKEND_URL="https://myapp.example.com" \
--set env.FRONTEND_URL="https://myapp.example.com" \
--set env.SECRET="CHANGE_ME_SECRET"
```
Adjust values to your registry and domain. The charts NOTES.txt includes additional examples.
3) Expose and access
- If using Cloudflare Tunnel or an ingress, configure DNS accordingly (see tofu/modules/cloudflare and deployment/tunnel.yaml).
- For quick testing without ingress:
```bash
kubectl -n myapp port-forward deploy/myapp-backend 8000:8000
kubectl -n myapp port-forward deploy/myapp-frontend 5173:80
```
### Verification
```bash
# Check pods
kubectl -n myapp get pods
# Backend health
curl -i http://127.0.0.1:8000/
# OpenAPI
open http://127.0.0.1:8000/docs
# Frontend (if port-forwarded)
open http://localhost:5173
```
## Testing Instructions
### Unit Tests
```bash
# Commands to run unit tests
# For example:
# go test ./...
# npm test
```
### Integration Tests
```bash
# Commands to run integration tests
# Any setup required for integration tests
```
### End-to-End Tests
```bash
# Commands to run e2e tests
# How to set up test environment
```
## Usage Examples
All endpoints are documented at OpenAPI: http://127.0.0.1:8000/docs
### Auth: Register and Login (JWT)
```bash
# Register
curl -X POST http://127.0.0.1:8000/auth/register \
-H 'Content-Type: application/json' \
-d '{
"email": "user@example.com",
"password": "StrongPassw0rd",
"first_name": "Jane",
"last_name": "Doe"
}'
# Login (JWT)
TOKEN=$(curl -s -X POST http://127.0.0.1:8000/auth/jwt/login \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'username=user@example.com&password=StrongPassw0rd' | jq -r .access_token)
echo $TOKEN
# Call a protected route
curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8000/authenticated-route
```
### Frontend
- Start with: npm run dev in 7project/frontend
- Ensure VITE_BACKEND_URL is set to the backend URL (e.g., http://127.0.0.1:8000)
- Open http://localhost:5173
- Login, view latest transactions, filter, and add new transactions from the UI.
---
## Presentation Video
**YouTube Link**: [Insert your YouTube link here]
**Duration**: [X minutes Y seconds]
**Video Includes**:
- [ ] Project overview and architecture
- [ ] Live demonstration of key features
- [ ] Code walkthrough
- [ ] Build and deployment showcase
## Troubleshooting
### Common Issues
#### Issue 1: [Common problem]
**Symptoms**: [What the user sees]
**Solution**: [Step-by-step fix]
#### Issue 2: [Another common problem]
**Symptoms**: [What the user sees]
**Solution**: [Step-by-step fix]
### Debug Commands
```bash
# Useful commands for debugging
# Log viewing commands
# Service status checks
```
---
## Self-Assessment Table
> Be honest and detailed in your assessments.
> This information is used for individual grading.
> Link to the specific commit on GitHub for each contribution.
| Task/Component | Assigned To | Status | Time Spent | Difficulty | Notes |
|-----------------------------------------------------------------------|-------------| ------------- |----------------|------------| ----------- |
| [Project Setup & Repository](https://github.com/dat515-2025/Group-8#) | Lukas | ✅ Complete | [X hours] | Medium | [Any notes] |
| [Design Document](https://github.com/dat515-2025/Group-8/blob/main/6design/design.md) | Both | ✅ Complete | 2 Hours | Easy | [Any notes] |
| [Backend API Development](https://github.com/dat515-2025/Group-8/tree/main/7project/backend/app/api) | Dejan | ✅ Complete | 10 hours | Medium | [Any notes] |
| [Database Setup & Models](https://github.com/dat515-2025/Group-8/tree/main/7project/backend/app/models) | Lukas | ✅ Complete | [X hours] | Medium | [Any notes] |
| [Frontend Development](https://github.com/dat515-2025/Group-8/tree/main/7project/frontend) | Dejan | 🔄 In Progress | 7 hours so far | Medium | [Any notes] |
| [Docker Configuration](https://github.com/dat515-2025/Group-8/blob/main/7project/compose.yml) | Lukas | ✅ Complete | [X hours] | Easy | [Any notes] |
| [Cloud Deployment](https://github.com/dat515-2025/Group-8/blob/main/7project/deployment/app-demo-deployment.yaml) | Lukas | ✅ Complete | [X hours] | Hard | [Any notes] |
| [Testing Implementation](https://github.com/dat515-2025/group-name) | Dejan | ❌ Not Started | [X hours] | Medium | [Any notes] |
| [Documentation](https://github.com/dat515-2025/group-name) | Both | ❌ Not Started | [X hours] | Easy | [Any notes] |
| [Presentation Video](https://github.com/dat515-2025/group-name) | Both | ❌ Not Started | [X hours] | Medium | [Any notes] |
**Legend**: ✅ Complete | 🔄 In Progress | ⏳ Pending | ❌ Not Started
## Hour Sheet
> Link to the specific commit on GitHub for each contribution.
### [Team Member 1 Name]
| Date | Activity | Hours | Description |
| --------- | ------------------- | ---------- | ----------------------------------- |
| [Date] | Initial Setup | [X.X] | Repository setup, project structure |
| [Date] | Backend Development | [X.X] | Implemented user authentication |
| [Date] | Testing | [X.X] | Unit tests for API endpoints |
| [Date] | Documentation | [X.X] | Updated README and design doc |
| **Total** | | **[XX.X]** | |
### Dejan
| Date | Activity | Hours | Description |
|-------------|----------------------|--------|--------------------------------|
| 25.9. | Design | 1.5 | 6design |
| 9-11.10. | Backend APIs | 10 | Implemented Backend APIs |
| 13-15.10. | Frontend Development | 6.5 | Created user interface mockups |
| Continually | Documantation | 3 | Documenting the dev process |
| **Total** | | **21** | |
### Group Total: [XXX.X] hours
---
## Final Reflection
### What We Learned
[Reflect on the key technical and collaboration skills learned during this project]
### Challenges Faced
[Describe the main challenges and how you overcame them]
### If We Did This Again
[What would you do differently? What worked well that you'd keep?]
### Individual Growth
#### [Team Member 1 Name]
[Personal reflection on growth, challenges, and learning]
#### [Team Member 2 Name]
[Personal reflection on growth, challenges, and learning]
---
**Report Completion Date**: [Date]
**Last Updated**: 15.10.2025

View File

@@ -42,23 +42,27 @@ provider "helm" {
} }
module "storage" { module "storage" {
source = "${path.module}/modules/storage" source = "./modules/storage"
}
module "metrics_server" {
source = "./modules/metrics-server"#
} }
module "loadbalancer" { module "loadbalancer" {
source = "${path.module}/modules/metallb" source = "./modules/metallb"
depends_on = [module.storage] depends_on = [module.storage]
metallb_ip_range = var.metallb_ip_range metallb_ip_range = var.metallb_ip_range
} }
module "cert-manager" { module "cert-manager" {
source = "${path.module}/modules/cert-manager" source = "./modules/cert-manager"
depends_on = [module.loadbalancer] depends_on = [module.loadbalancer]
} }
module "cloudflare" { module "cloudflare" {
source = "${path.module}/modules/cloudflare" source = "./modules/cloudflare"
depends_on = [module.cert-manager] depends_on = [module.cert-manager]
cloudflare_api_token = var.cloudflare_api_token cloudflare_api_token = var.cloudflare_api_token
@@ -69,14 +73,14 @@ module "cloudflare" {
} }
module "monitoring" { module "monitoring" {
source = "${path.module}/modules/prometheus" source = "./modules/prometheus"
depends_on = [module.cloudflare] depends_on = [module.cloudflare]
cloudflare_domain = var.cloudflare_domain cloudflare_domain = var.cloudflare_domain
} }
module "database" { module "database" {
source = "${path.module}/modules/maxscale" source = "./modules/maxscale"
depends_on = [module.monitoring] depends_on = [module.monitoring]
mariadb_password = var.mariadb_password mariadb_password = var.mariadb_password
@@ -92,6 +96,13 @@ module "database" {
phpmyadmin_enabled = var.phpmyadmin_enabled phpmyadmin_enabled = var.phpmyadmin_enabled
cloudflare_domain = var.cloudflare_domain cloudflare_domain = var.cloudflare_domain
s3_enabled = var.s3_enabled
s3_bucket = var.s3_bucket
s3_region = var.s3_region
s3_endpoint = var.s3_endpoint
s3_key_id = var.s3_key_id
s3_key_secret = var.s3_key_secret
} }
#module "argocd" { #module "argocd" {
@@ -109,7 +120,7 @@ module "database" {
#} #}
module "rabbitmq" { module "rabbitmq" {
source = "${path.module}/modules/rabbitmq" source = "./modules/rabbitmq"
depends_on = [module.database] depends_on = [module.database]
base_domain = var.cloudflare_domain base_domain = var.cloudflare_domain
rabbitmq-password = var.rabbitmq-password rabbitmq-password = var.rabbitmq-password

View File

@@ -1,4 +1,4 @@
apiVersion: v2 apiVersion: v2
name: maxscale-helm name: maxscale-helm
version: 1.0.7 version: 1.0.14
description: Helm chart for MaxScale related Kubernetes manifests description: Helm chart for MaxScale related Kubernetes manifests

View File

@@ -0,0 +1,42 @@
{{- if .Values.s3.enabled }}
apiVersion: k8s.mariadb.com/v1alpha1
kind: Backup
metadata:
name: backup
namespace: mariadb-operator
spec:
mariaDbRef:
name: mariadb-repl
namespace: mariadb-operator
schedule:
cron: "0 */3 * * *"
suspend: false
timeZone: "Europe/Prague"
maxRetention: 720h # 30 days
compression: bzip2
storage:
s3:
bucket: {{ .Values.s3.bucket | quote }}
endpoint: {{ .Values.s3.endpoint | quote }}
accessKeyIdSecretKeyRef:
name: s3-credentials
key: key_id
secretAccessKeySecretKeyRef:
name: s3-credentials
key: secret_key
region: {{ .Values.s3.region | quote }}
tls:
enabled: true
# Define a PVC to use as staging area for keeping the backups while they are being processed.
stagingStorage:
persistentVolumeClaim:
resources:
requests:
storage: 10Gi
accessModes:
- ReadWriteOnce
args:
- --single-transaction
- --all-databases
logLevel: info
{{- end }}

View File

@@ -60,6 +60,8 @@ spec:
scrapeTimeout: 10s scrapeTimeout: 10s
prometheusRelease: kube-prometheus-stack prometheusRelease: kube-prometheus-stack
jobLabel: mariadb-monitoring jobLabel: mariadb-monitoring
auth:
generate: true
tls: tls:
enabled: true enabled: true

View File

@@ -0,0 +1,11 @@
{{- if .Values.s3.enabled }}
apiVersion: v1
kind: Secret
metadata:
name: s3-credentials
namespace: mariadb-operator
type: Opaque
stringData:
key_id: "{{ .Values.s3.key_id }}"
secret_key: "{{ .Values.s3.key_secret }}"
{{- end }}

View File

@@ -28,7 +28,7 @@ spec:
- name: DATABASE_ENABLE_SSL - name: DATABASE_ENABLE_SSL
value: "yes" value: "yes"
- name: DATABASE_HOST - name: DATABASE_HOST
value: "mariadb-repl" value: "mariadb-repl-maxscale-internal"
- name: DATABASE_PORT_NUMBER - name: DATABASE_PORT_NUMBER
value: "3306" value: "3306"
- name: PHPMYADMIN_ALLOW_NO_PASSWORD - name: PHPMYADMIN_ALLOW_NO_PASSWORD

View File

@@ -14,4 +14,12 @@ metallb:
phpmyadmin: phpmyadmin:
enabled: true enabled: true
s3:
enabled: false
endpoint: ""
region: ""
bucket: ""
key_id: ""
key_secret: ""
base_domain: example.com base_domain: example.com

View File

@@ -9,16 +9,16 @@ terraform {
version = "3.0.2" version = "3.0.2"
} }
kubernetes = { kubernetes = {
source = "hashicorp/kubernetes" source = "hashicorp/kubernetes"
version = "2.38.0" version = "2.38.0"
} }
} }
} }
resource "kubernetes_namespace" "mariadb-operator" { resource "kubernetes_namespace" "mariadb-operator" {
metadata { metadata {
name = "mariadb-operator" name = "mariadb-operator"
} }
} }
locals { locals {
@@ -30,46 +30,53 @@ locals {
} }
resource "kubectl_manifest" "secrets" { resource "kubectl_manifest" "secrets" {
yaml_body = local.mariadb_secret_yaml yaml_body = local.mariadb_secret_yaml
depends_on = [ kubernetes_namespace.mariadb-operator ] depends_on = [kubernetes_namespace.mariadb-operator]
} }
resource "helm_release" "mariadb-operator-crds" { resource "helm_release" "mariadb-operator-crds" {
name = "mariadb-operator-crds" name = "mariadb-operator-crds"
repository = "https://helm.mariadb.com/mariadb-operator" repository = "https://helm.mariadb.com/mariadb-operator"
chart = "mariadb-operator-crds" chart = "mariadb-operator-crds"
namespace = "mariadb-operator" namespace = "mariadb-operator"
version = "25.8.4" version = "25.8.4"
depends_on = [ kubectl_manifest.secrets ] depends_on = [kubectl_manifest.secrets]
timeout = 3600 timeout = 3600
} }
resource "helm_release" "mariadb-operator" { resource "helm_release" "mariadb-operator" {
name = "mariadb-operator" name = "mariadb-operator"
repository = "https://helm.mariadb.com/mariadb-operator" repository = "https://helm.mariadb.com/mariadb-operator"
chart = "mariadb-operator" chart = "mariadb-operator"
depends_on = [ helm_release.mariadb-operator-crds, kubectl_manifest.secrets ] depends_on = [helm_release.mariadb-operator-crds, kubectl_manifest.secrets]
namespace = "mariadb-operator" namespace = "mariadb-operator"
timeout = 3600 version = "25.8.3"
timeout = 3600
} }
resource "helm_release" "maxscale_helm" { resource "helm_release" "maxscale_helm" {
name = "maxscale-helm" name = "maxscale-helm"
chart = "${path.module}/charts/maxscale-helm" chart = "${path.module}/charts/maxscale-helm"
version = "1.0.7" version = "1.0.14"
depends_on = [ helm_release.mariadb-operator-crds, kubectl_manifest.secrets ] depends_on = [helm_release.mariadb-operator-crds, kubectl_manifest.secrets]
timeout = 3600 timeout = 3600
set = [ set = [
{ name = "user.name", value = var.mariadb_user_name }, { name = "user.name", value = var.mariadb_user_name },
{ name = "user.host", value = var.mariadb_user_host }, { name = "user.host", value = var.mariadb_user_host },
{ name = "metallb.maxscale_ip", value = var.maxscale_ip }, { name = "metallb.maxscale_ip", value = var.maxscale_ip },
{ name = "metallb.service_ip", value = var.service_ip }, { name = "metallb.service_ip", value = var.service_ip },
{ name = "metallb.primary_ip", value = var.primary_ip }, { name = "metallb.primary_ip", value = var.primary_ip },
{ name = "metallb.secondary_ip", value = var.secondary_ip }, { name = "metallb.secondary_ip", value = var.secondary_ip },
{ name = "phpmyadmin.enabled", value = tostring(var.phpmyadmin_enabled) }, { name = "phpmyadmin.enabled", value = tostring(var.phpmyadmin_enabled) },
{ name = "base_domain", value = var.cloudflare_domain } { name = "base_domain", value = var.cloudflare_domain },
{ name = "s3.key_id", value = var.s3_key_id },
{ name = "s3.key_secret", value = var.s3_key_secret },
{ name = "s3.enabled", value = var.s3_enabled },
{ name = "s3.endpoint", value = var.s3_endpoint },
{ name = "s3.region", value = var.s3_region },
{ name = "s3.bucket", value = var.s3_bucket },
] ]
} }

View File

@@ -52,7 +52,39 @@ variable "mariadb_user_password" {
} }
variable "cloudflare_domain" { variable "cloudflare_domain" {
type = string type = string
default = "Base cloudflare domain, e.g. example.com" default = "Base cloudflare domain, e.g. example.com"
nullable = false nullable = false
} }
variable "s3_key_id" {
description = "S3 Key ID for backups"
type = string
sensitive = true
}
variable "s3_key_secret" {
description = "S3 Key Secret for backups"
type = string
sensitive = true
}
variable "s3_enabled" {
description = "Enable S3 backups"
type = bool
}
variable "s3_endpoint" {
description = "S3 endpoint for backups"
type = string
}
variable "s3_region" {
description = "S3 region for backups"
type = string
}
variable "s3_bucket" {
description = "S3 bucket name for backups"
type = string
}

View File

@@ -0,0 +1,33 @@
terraform {
required_providers {
kubectl = {
source = "gavinbunney/kubectl"
version = "1.19.0"
}
helm = {
source = "hashicorp/helm"
version = "3.0.2"
}
kubernetes = {
source = "hashicorp/kubernetes"
version = "2.38.0"
}
}
}
# Deploy metrics-server via Helm into kube-system namespace
resource "helm_release" "metrics_server" {
name = "metrics-server"
repository = "https://kubernetes-sigs.github.io/metrics-server/"
chart = "metrics-server"
namespace = "kube-system"
wait = true
timeout = 600
recreate_pods = false
force_update = false
values = [
file("${path.module}/values.yaml")
]
}

View File

@@ -0,0 +1,15 @@
# Values overriding defaults for metrics-server Helm chart
# Fix TLS and address selection issues when scraping kubelets (common on Talos)
args:
- --kubelet-insecure-tls
- --kubelet-preferred-address-types=InternalIP,Hostname,InternalDNS,ExternalDNS,ExternalIP
- --kubelet-use-node-status-port=true
# Using hostNetwork often helps in restricted CNI/DNS environments
#hostNetwork: true
# Required when hostNetwork is true so DNS works as expected
#dnsPolicy: ClusterFirstWithHostNet
# Enable metrics API service monitor if Prometheus Operator is present (optional)
# serviceMonitor:
# enabled: true

View File

@@ -16,6 +16,12 @@ terraform {
} }
} }
resource "kubernetes_namespace" "rabbitmq_namespace" {
metadata {
name = "rabbitmq-system"
}
}
resource "helm_release" "rabbitmq_operator" { resource "helm_release" "rabbitmq_operator" {
name = "rabbitmq-cluster-operator" name = "rabbitmq-cluster-operator"
@@ -24,8 +30,7 @@ resource "helm_release" "rabbitmq_operator" {
version = "4.4.34" version = "4.4.34"
namespace = "rabbitmq-system" namespace = "rabbitmq-system"
create_namespace = true
# Zde můžete přepsat výchozí hodnoty chartu, pokud by bylo potřeba # Zde můžete přepsat výchozí hodnoty chartu, pokud by bylo potřeba
# Například sledovat jen určité namespace, nastavit tolerations atd. # Například sledovat jen určité namespace, nastavit tolerations atd.
@@ -59,6 +64,7 @@ resource "helm_release" "rabbitmq_operator" {
value = "true" value = "true"
} }
] ]
depends_on = [kubernetes_namespace.rabbitmq_namespace]
} }

View File

@@ -2,4 +2,4 @@ apiVersion: rabbitmq.com/v1beta1
kind: RabbitmqCluster kind: RabbitmqCluster
metadata: metadata:
name: 'rabbitmq-cluster' name: 'rabbitmq-cluster'
namespace: "rabbitmq" namespace: "rabbitmq-system"

View File

@@ -2,7 +2,7 @@ apiVersion: networking.cfargotunnel.com/v1alpha1
kind: TunnelBinding kind: TunnelBinding
metadata: metadata:
name: rabbit-tunnel-binding name: rabbit-tunnel-binding
namespace: rabbitmq namespace: rabbitmq-system
subjects: subjects:
- name: rabbit-gui - name: rabbit-gui
spec: spec:

View File

@@ -108,3 +108,40 @@ variable "rabbitmq-password" {
sensitive = true sensitive = true
description = "Admin password for RabbitMQ user" description = "Admin password for RabbitMQ user"
} }
variable "s3_key_id" {
description = "S3 Key ID for backups"
type = string
sensitive = true
nullable = false
}
variable "s3_key_secret" {
description = "S3 Key Secret for backups"
type = string
sensitive = true
nullable = false
}
variable "s3_enabled" {
description = "Enable S3 backups"
type = bool
}
variable "s3_endpoint" {
description = "S3 endpoint for backups"
type = string
}
variable "s3_region" {
description = "S3 region for backups"
type = string
}
variable "s3_bucket" {
description = "S3 bucket name for backups"
type = string
}