Compare commits

..

44 Commits

Author SHA1 Message Date
ribardej
e78b8c2e6b fix(tests): fixed a service test and one warning regarding 422 status
Some checks failed
Run Python Tests / build-and-test (push) Has been cancelled
2025-10-22 14:53:45 +02:00
ribardej
aade88beb9 fix(backend): added a default value for FRONTEND_DOMAIN_SCHEME so the tests dont crash 2025-10-22 14:48:56 +02:00
Dejan Ribarovski
5305531950 Merge branch 'main' into 30-create-tests-and-set-up-a-github-pipeline 2025-10-22 14:39:28 +02:00
ribardej
6d8a6a55c0 fix(backend): refactored app to fastApi to avoid CORS errors 2025-10-22 14:37:48 +02:00
396047574a fix(auth): increase timeout
Some checks are pending
Deploy Prod / Build and push image (reusable) (push) Waiting to run
Deploy Prod / Generate Production URLs (push) Waiting to run
Deploy Prod / Frontend - Build and Deploy to Cloudflare Pages (prod) (push) Blocked by required conditions
Deploy Prod / Helm upgrade/install (prod) (push) Blocked by required conditions
2025-10-21 22:43:30 +02:00
d926168ef9 fix(oauth): use prod MojeID 2025-10-21 22:24:39 +02:00
41956b8e0c Merge pull request #32 from dat515-2025/merge/fix_react_oauth
feat(oauth): add csas connection, allow oauth from react
2025-10-21 22:13:54 +02:00
9734895758 feat(oauth): add to env 2025-10-21 22:11:32 +02:00
91a32b2f10 feat(oauth): add to env 2025-10-21 22:08:00 +02:00
2b640fc6ac Update 7project/backend/app/oauth/csas.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-21 22:03:13 +02:00
3ebf47e371 feat(oauth): add csas connection, allow oauth from react 2025-10-21 22:01:09 +02:00
Dejan Ribarovski
40d07677bd Potential fix for code scanning alert no. 11: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-10-21 15:57:23 +02:00
ribardej
76eb2cce41 feat(tests): Added tests to PR and Prod workflows 2025-10-21 15:44:33 +02:00
ribardej
391e9da0c4 feat(tests): Implemented basic tests and github workflow 2025-10-21 15:36:49 +02:00
Dejan Ribarovski
be4a3b401a Merge pull request #28 from dat515-2025/merge/frontend_basics
Some checks are pending
Deploy Prod / Build and push image (reusable) (push) Waiting to run
Deploy Prod / Generate Production URLs (push) Waiting to run
Deploy Prod / Frontend - Build and Deploy to Cloudflare Pages (prod) (push) Blocked by required conditions
Deploy Prod / Helm upgrade/install (prod) (push) Blocked by required conditions
Merge/frontend basics
2025-10-21 13:34:53 +02:00
8c72091658 Merge branch 'main' into merge/frontend_basics 2025-10-21 13:31:50 +02:00
607c5eadd7 feat(infrastructure): remove old deployment 2025-10-20 19:20:56 +02:00
2617c640a8 fix(app): add missing env variables
Some checks failed
Deploy Prod / Build and push image (reusable) (push) Has been cancelled
Deploy Prod / Generate Production URLs (push) Has been cancelled
Deploy Prod / Frontend - Build and Deploy to Cloudflare Pages (prod) (push) Has been cancelled
Deploy Prod / Helm upgrade/install (prod) (push) Has been cancelled
2025-10-17 16:04:52 +02:00
cb9ef5e461 feat(app): add sentry loging 2025-10-17 15:59:18 +02:00
d593f7a994 feat(infrastructure): move to secrets
Some checks are pending
Deploy Prod / Build and push image (reusable) (push) Waiting to run
Deploy Prod / Generate Production URLs (push) Waiting to run
Deploy Prod / Frontend - Build and Deploy to Cloudflare Pages (prod) (push) Blocked by required conditions
Deploy Prod / Helm upgrade/install (prod) (push) Blocked by required conditions
2025-10-16 18:30:13 +02:00
ef5b3f2d30 feat(infrastructure): move to secrets 2025-10-16 18:25:06 +02:00
60109c4a35 fix(infrastructure): add oauth keys as secret 2025-10-16 18:18:19 +02:00
b6f9ee8fc7 fix(infrastructure): add missing slash 2025-10-16 18:11:19 +02:00
52333b24d5 Merge pull request #29 from dat515-2025/merge/deployment_envs
fix(infrastructure): add env variables to deployment
2025-10-16 18:05:59 +02:00
8929920072 Potential fix for code scanning alert no. 9: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-10-16 18:04:04 +02:00
cdb6cf5e20 Update .github/workflows/deploy-pr.yaml
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-16 18:02:09 +02:00
5190e9c48e fix(infrastructure): use correct runner 2025-10-16 18:00:07 +02:00
815bf7f065 fix(infrastructure): use correct runner 2025-10-16 17:50:39 +02:00
85a390565a fix(infrastructure): use correct runner 2025-10-16 17:43:55 +02:00
20d26b7edc fix(infrastructure): use correct runner 2025-10-16 17:42:16 +02:00
579dda50b9 fix(infrastructure): use correct runner 2025-10-16 17:42:02 +02:00
4f7d30daf6 fix(infrastructure): use correct runner 2025-10-16 17:32:00 +02:00
49c96187c9 fix(infrastructure): use correct runner 2025-10-16 17:17:41 +02:00
d1feafd4ef fix(infrastructure): use correct runner 2025-10-16 17:12:01 +02:00
efb454ba99 fix(infrastructure): use correct runner 2025-10-16 17:06:06 +02:00
810f1ccb32 fix(infrastructure): use correct runner 2025-10-16 17:01:38 +02:00
c4afdf5ad2 fix(infrastructure): use correct runner 2025-10-16 15:10:33 +02:00
c290a109b6 fix(infrastructure): use variables, not secrets 2025-10-16 15:01:53 +02:00
7c161f6f37 fix(infrastructure): add env variables to deployment 2025-10-16 14:49:26 +02:00
c4991ea3c4 fix(infrastructure): add env variables to deployment 2025-10-16 14:47:16 +02:00
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
42 changed files with 1330 additions and 444 deletions

View File

@@ -9,6 +9,29 @@ permissions:
pull-requests: write pull-requests: write
jobs: jobs:
test:
name: Run Python Tests
if: github.event.action != 'closed'
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run tests with pytest
run: pytest
working-directory: ./7project/backend
build: build:
if: github.event.action != 'closed' if: github.event.action != 'closed'
name: Build and push image (reusable) name: Build and push image (reusable)
@@ -20,13 +43,26 @@ jobs:
pr_number: ${{ github.event.pull_request.number }} pr_number: ${{ github.event.pull_request.number }}
secrets: inherit secrets: inherit
get_urls:
if: github.event.action != 'closed'
name: Generate Preview URLs
uses: ./.github/workflows/url_generator.yml
with:
runner: vhs
mode: pr
pr_number: ${{ github.event.pull_request.number }}
base_domain: ${{ vars.DEV_BASE_DOMAIN }}
secrets: inherit
frontend: frontend:
if: github.event.action != 'closed' if: github.event.action != 'closed'
name: Frontend - Build and Deploy to Cloudflare Pages (PR) name: Frontend - Build and Deploy to Cloudflare Pages (PR)
needs: [get_urls]
uses: ./.github/workflows/frontend-pages.yml uses: ./.github/workflows/frontend-pages.yml
with: with:
mode: pr mode: pr
pr_number: ${{ github.event.pull_request.number }} pr_number: ${{ github.event.pull_request.number }}
backend_url_scheme: ${{ needs.get_urls.outputs.backend_url_scheme }}
secrets: inherit secrets: inherit
deploy: deploy:
@@ -36,7 +72,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, frontend] needs: [build, frontend, get_urls]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -62,25 +98,24 @@ jobs:
DEV_BASE_DOMAIN: ${{ secrets.BASE_DOMAIN }} DEV_BASE_DOMAIN: ${{ secrets.BASE_DOMAIN }}
RABBITMQ_PASSWORD: ${{ secrets.PROD_RABBITMQ_PASSWORD }} RABBITMQ_PASSWORD: ${{ secrets.PROD_RABBITMQ_PASSWORD }}
DB_PASSWORD: ${{ secrets.PROD_DB_PASSWORD }} DB_PASSWORD: ${{ secrets.PROD_DB_PASSWORD }}
IMAGE_REPO: ${{ needs.build.outputs.image_repo }}
DIGEST: ${{ needs.build.outputs.digest }} DIGEST: ${{ needs.build.outputs.digest }}
DOMAIN: "${{ needs.get_urls.outputs.backend_url }}"
DOMAIN_SCHEME: "${{ needs.get_urls.outputs.backend_url_scheme }}"
FRONTEND_DOMAIN: "${{ needs.get_urls.outputs.frontend_url }}"
FRONTEND_DOMAIN_SCHEME: "${{ needs.get_urls.outputs.frontend_url_scheme }}"
run: | run: |
PR=${{ github.event.pull_request.number }} PR=${{ github.event.pull_request.number }}
if [ -z "$PR" ]; then echo "PR number missing"; exit 1; fi
if [ -z "$DEV_BASE_DOMAIN" ]; then echo "Secret DEV_BASE_DOMAIN is required (e.g., dev.example.com)"; exit 1; fi
if [ -z "$RABBITMQ_PASSWORD" ]; then echo "Secret DEV_RABBITMQ_PASSWORD is required"; exit 1; fi
if [ -z "$DB_PASSWORD" ]; then echo "Secret DEV_DB_PASSWORD is required"; exit 1; fi
RELEASE=myapp-pr-$PR RELEASE=myapp-pr-$PR
NAMESPACE=pr-$PR NAMESPACE=pr-$PR
DOMAIN=pr-$PR.$DEV_BASE_DOMAIN
if [ -z "$IMAGE_REPO" ]; then IMAGE_REPO="lukastrkan/cc-app-demo"; fi
helm upgrade --install "$RELEASE" ./7project/charts/myapp-chart \ helm upgrade --install "$RELEASE" ./7project/charts/myapp-chart \
-n "$NAMESPACE" --create-namespace \ -n "$NAMESPACE" --create-namespace \
-f 7project/charts/myapp-chart/values-dev.yaml \ -f 7project/charts/myapp-chart/values-dev.yaml \
--set prNumber="$PR" \ --set prNumber="$PR" \
--set deployment="pr-$PR" \ --set deployment="pr-$PR" \
--set domain="$DOMAIN" \ --set domain="$DOMAIN" \
--set image.repository="$IMAGE_REPO" \ --set domain_scheme="$DOMAIN_SCHEME" \
--set frontend_domain="$FRONTEND_DOMAIN" \
--set frontend_domain_scheme="$FRONTEND_DOMAIN_SCHEME" \
--set image.digest="$DIGEST" \ --set image.digest="$DIGEST" \
--set-string rabbitmq.password="$RABBITMQ_PASSWORD" \ --set-string rabbitmq.password="$RABBITMQ_PASSWORD" \
--set-string database.password="$DB_PASSWORD" --set-string database.password="$DB_PASSWORD"
@@ -88,19 +123,16 @@ jobs:
- name: Post preview URLs 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 }} BACKEND_URL: ${{ needs.get_urls.outputs.backend_url_scheme }}
FRONTEND_URL: ${{ needs.frontend.outputs.deployed_url }} FRONTEND_URL: ${{ needs.get_urls.outputs.frontend_url_scheme }}
with: with:
script: | script: |
const pr = context.payload.pull_request; const pr = context.payload.pull_request;
if (!pr) { core.setFailed('No pull_request context'); return; } if (!pr) { core.setFailed('No pull_request context'); return; }
const prNumber = pr.number; const prNumber = pr.number;
const domainBase = process.env.DEV_BASE_DOMAIN; const backendUrl = process.env.BACKEND_URL || '(not available)';
if (!domainBase) { core.setFailed('DEV_BASE_DOMAIN is required'); return; }
const backendDomain = `pr-${prNumber}.${domainBase}`;
const backendUrl = `https://${backendDomain}`;
const frontendUrl = process.env.FRONTEND_URL || '(not available)'; const frontendUrl = process.env.FRONTEND_URL || '(not available)';
const marker = '<!-- preview-link -->'; const marker = '<!-- preview-comment-marker -->';
const body = `${marker}\nPreview environment is running\n- Frontend: ${frontendUrl}\n- Backend: ${backendUrl}\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 });
@@ -139,4 +171,4 @@ jobs:
NAMESPACE=pr-$PR NAMESPACE=pr-$PR
helm uninstall "$RELEASE" -n "$NAMESPACE" || true helm uninstall "$RELEASE" -n "$NAMESPACE" || true
# Optionally delete the namespace if empty # Optionally delete the namespace if empty
kubectl delete namespace "$NAMESPACE" --ignore-not-found=true || true kubectl delete namespace "$NAMESPACE" --ignore-not-found=true || true

View File

@@ -21,6 +21,29 @@ concurrency:
cancel-in-progress: false cancel-in-progress: false
jobs: jobs:
test:
name: Run Python Tests
if: github.event.action != 'closed'
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run tests with pytest
run: pytest
working-directory: ./7project/backend
build: build:
name: Build and push image (reusable) name: Build and push image (reusable)
uses: ./.github/workflows/build-image.yaml uses: ./.github/workflows/build-image.yaml
@@ -30,17 +53,28 @@ jobs:
context: 7project/backend context: 7project/backend
secrets: inherit secrets: inherit
get_urls:
name: Generate Production URLs
uses: ./.github/workflows/url_generator.yml
with:
mode: prod
runner: vhs
base_domain: ${{ vars.PROD_DOMAIN }}
secrets: inherit
frontend: frontend:
name: Frontend - Build and Deploy to Cloudflare Pages (prod) name: Frontend - Build and Deploy to Cloudflare Pages (prod)
needs: [get_urls]
uses: ./.github/workflows/frontend-pages.yml uses: ./.github/workflows/frontend-pages.yml
with: with:
mode: prod mode: prod
backend_url_scheme: ${{ needs.get_urls.outputs.backend_url_scheme }}
secrets: inherit secrets: inherit
deploy: deploy:
name: Helm upgrade/install (prod) name: Helm upgrade/install (prod)
runs-on: vhs runs-on: vhs
needs: [build, frontend] needs: [build, frontend, get_urls]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -63,25 +97,36 @@ jobs:
- name: Helm upgrade/install prod - name: Helm upgrade/install prod
env: env:
DOMAIN: ${{ secrets.PROD_DOMAIN }} DOMAIN: ${{ needs.get_urls.outputs.backend_url }}
DOMAIN_SCHEME: ${{ needs.get_urls.outputs.backend_url_scheme }}
FRONTEND_DOMAIN: ${{ needs.get_urls.outputs.frontend_url }}
FRONTEND_DOMAIN_SCHEME: ${{ needs.get_urls.outputs.frontend_url_scheme }}
RABBITMQ_PASSWORD: ${{ secrets.PROD_RABBITMQ_PASSWORD }} RABBITMQ_PASSWORD: ${{ secrets.PROD_RABBITMQ_PASSWORD }}
DB_PASSWORD: ${{ secrets.PROD_DB_PASSWORD }} DB_PASSWORD: ${{ secrets.PROD_DB_PASSWORD }}
IMAGE_REPO: ${{ needs.build.outputs.image_repo }}
DIGEST: ${{ needs.build.outputs.digest }} DIGEST: ${{ needs.build.outputs.digest }}
BANKID_CLIENT_ID: ${{ secrets.BANKID_CLIENT_ID }}
BANKID_CLIENT_SECRET: ${{ secrets.BANKID_CLIENT_SECRET }}
MOJEID_CLIENT_ID: ${{ secrets.MOJEID_CLIENT_ID }}
MOJEID_CLIENT_SECRET: ${{ secrets.MOJEID_CLIENT_SECRET }}
CSAS_CLIENT_ID: ${{ secrets.CSAS_CLIENT_ID }}
CSAS_CLIENT_SECRET: ${{ secrets.CSAS_CLIENT_SECRET }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
run: | run: |
if [ -z "$DOMAIN" ]; then
echo "Secret PROD_DOMAIN is required (e.g., app.example.com)"; exit 1; fi
if [ -z "$RABBITMQ_PASSWORD" ]; then
echo "Secret PROD_RABBITMQ_PASSWORD is required"; exit 1; fi
if [ -z "$DB_PASSWORD" ]; then
echo "Secret PROD_DB_PASSWORD is required"; exit 1; fi
if [ -z "$IMAGE_REPO" ]; then IMAGE_REPO="lukastrkan/cc-app-demo"; fi
helm upgrade --install myapp ./7project/charts/myapp-chart \ helm upgrade --install myapp ./7project/charts/myapp-chart \
-n prod --create-namespace \ -n prod --create-namespace \
-f 7project/charts/myapp-chart/values-prod.yaml \ -f 7project/charts/myapp-chart/values-prod.yaml \
--set deployment="prod" \ --set deployment="prod" \
--set domain="$DOMAIN" \ --set domain="$DOMAIN" \
--set image.repository="$IMAGE_REPO" \ --set domain_scheme="$DOMAIN_SCHEME" \
--set frontend_domain="$FRONTEND_DOMAIN" \
--set frontend_domain_scheme="$FRONTEND_DOMAIN_SCHEME" \
--set image.digest="$DIGEST" \ --set image.digest="$DIGEST" \
--set-string rabbitmq.password="$RABBITMQ_PASSWORD" \ --set-string rabbitmq.password="$RABBITMQ_PASSWORD" \
--set-string database.password="$DB_PASSWORD" --set-string database.password="$DB_PASSWORD" \
--set-string oauth.bankid.clientId="$BANKID_CLIENT_ID" \
--set-string oauth.bankid.clientSecret="$BANKID_CLIENT_SECRET" \
--set-string oauth.mojeid.clientId="$MOJEID_CLIENT_ID" \
--set-string oauth.mojeid.clientSecret="$MOJEID_CLIENT_SECRET" \
--set-string oauth.csas.clientId="$CSAS_CLIENT_ID" \
--set-string oauth.csas.clientSecret="$CSAS_CLIENT_SECRET" \
--set-string sentry_dsn="$SENTRY_DSN" \

View File

@@ -15,6 +15,10 @@ on:
description: 'Cloudflare Pages project name (overrides default)' description: 'Cloudflare Pages project name (overrides default)'
required: false required: false
type: string type: string
backend_url_scheme:
description: 'The full scheme URL for the backend (e.g., https://api.example.com)'
required: true
type: string
secrets: secrets:
CLOUDFLARE_API_TOKEN: CLOUDFLARE_API_TOKEN:
required: true required: true
@@ -25,14 +29,6 @@ on:
description: 'URL of deployed frontend' description: 'URL of deployed frontend'
value: ${{ jobs.deploy.outputs.deployed_url }} 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: jobs:
build: build:
name: Build frontend name: Build frontend
@@ -54,50 +50,9 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Compute backend URL for Vite - name: Set backend URL from workflow input
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: | run: |
set -euo pipefail echo "VITE_BACKEND_URL=${{ inputs.backend_url_scheme }}" >> $GITHUB_ENV
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 - name: Build
run: npm run build run: npm run build
@@ -177,4 +132,4 @@ jobs:
else else
URL="https://${PBRANCH}.${PNAME}.pages.dev" URL="https://${PBRANCH}.${PNAME}.pages.dev"
fi fi
echo "deployed_url=$URL" >> $GITHUB_OUTPUT echo "deployed_url=$URL" >> $GITHUB_OUTPUT

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

@@ -0,0 +1,55 @@
name: Run Python Tests
permissions:
contents: read
# -----------------
# --- Triggers ----
# -----------------
# This section defines when the workflow will run.
on:
# Run on every push to the 'main' branch
push:
branches: [ "main", "30-create-tests-and-set-up-a-github-pipeline" ]
# Also run on every pull request that targets the 'main' branch
pull_request:
branches: [ "main" ]
# -----------------
# ------ Jobs -----
# -----------------
# A workflow is made up of one or more jobs that can run in parallel or sequentially.
jobs:
# A descriptive name for your job
build-and-test:
# Specifies the virtual machine to run the job on. 'ubuntu-latest' is a common and cost-effective choice.
runs-on: ubuntu-latest
# -----------------
# ----- Steps -----
# -----------------
# A sequence of tasks that will be executed as part of the job.
steps:
# Step 1: Check out your repository's code
# This action allows the workflow to access your code.
- name: Check out repository code
uses: actions/checkout@v4
# Step 2: Set up the Python environment
# This action installs a specific version of Python on the runner.
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11' # Use the Python version that matches your project
# Step 3: Install project dependencies
# Runs shell commands to install the libraries listed in your requirements.txt.
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
# Step 4: Run your tests!
# Executes the pytest command to run your test suite.
- name: Run tests with pytest
run: pytest
working-directory: ./7project/backend

74
.github/workflows/url_generator.yml vendored Normal file
View File

@@ -0,0 +1,74 @@
name: Generate Preview or Production URLs
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
runner:
description: 'The runner to use for this job'
required: false
type: string
default: 'ubuntu-latest'
base_domain:
description: 'The base domain for production URLs (e.g., example.com)'
required: true
type: string
outputs:
backend_url:
description: "The backend URL without scheme (e.g., api.example.com)"
value: ${{ jobs.generate-urls.outputs.backend_url }}
frontend_url:
description: "The frontend URL without scheme (e.g., app.example.com)"
value: ${{ jobs.generate-urls.outputs.frontend_url }}
backend_url_scheme:
description: "The backend URL with scheme (e.g., https://api.example.com)"
value: ${{ jobs.generate-urls.outputs.backend_url_scheme }}
frontend_url_scheme:
description: "The frontend URL with scheme (e.g., https://app.example.com)"
value: ${{ jobs.generate-urls.outputs.frontend_url_scheme }}
jobs:
generate-urls:
permissions:
contents: none
runs-on: ${{ inputs.runner }}
outputs:
backend_url: ${{ steps.set_urls.outputs.backend_url }}
frontend_url: ${{ steps.set_urls.outputs.frontend_url }}
backend_url_scheme: ${{ steps.set_urls.outputs.backend_url_scheme }}
frontend_url_scheme: ${{ steps.set_urls.outputs.frontend_url_scheme }}
steps:
- name: Generate URLs
id: set_urls
env:
BASE_DOMAIN: ${{ inputs.base_domain }}
run: |
set -euo pipefail
if [ "${{ inputs.mode }}" = "prod" ]; then
BACKEND_URL="api.${BASE_DOMAIN}"
FRONTEND_URL="finance.${BASE_DOMAIN}"
else
# This is your current logic
FRONTEND_URL="pr-${{ inputs.pr_number }}.group-8-frontend.pages.dev"
BACKEND_URL="api-pr-${{ inputs.pr_number }}.${BASE_DOMAIN}"
fi
FRONTEND_URL_SCHEME="https://$FRONTEND_URL"
BACKEND_URL_SCHEME="https://$BACKEND_URL"
# This part correctly writes to GITHUB_OUTPUT for the step
echo "backend_url_scheme=$BACKEND_URL_SCHEME" >> $GITHUB_OUTPUT
echo "frontend_url_scheme=$FRONTEND_URL_SCHEME" >> $GITHUB_OUTPUT
echo "backend_url=$BACKEND_URL" >> $GITHUB_OUTPUT
echo "frontend_url=$FRONTEND_URL" >> $GITHUB_OUTPUT

View File

@@ -0,0 +1,32 @@
"""add config to user
Revision ID: eabec90a94fe
Revises: 5ab2e654c96e
Create Date: 2025-10-21 18:56:42.085973
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'eabec90a94fe'
down_revision: Union[str, Sequence[str], None] = '5ab2e654c96e'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('user', sa.Column('config', sa.JSON(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('user', 'config')
# ### end Alembic commands ###

View File

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

View File

@@ -6,15 +6,30 @@ from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from starlette.requests import Request from starlette.requests import Request
from app.models.user import User from app.services import bank_scraper
from app.workers.celery_tasks import load_transactions, load_all_transactions
from app.models.user import User, OAuthAccount
from app.services.user_service import current_active_verified_user from app.services.user_service import current_active_verified_user
from app.api.auth import router as auth_router from app.api.auth import router as auth_router
from app.api.csas import router as csas_router
from app.api.categories import router as categories_router from app.api.categories import router as categories_router
from app.api.transactions import router as transactions_router from app.api.transactions import router as transactions_router
from app.services.user_service import auth_backend, current_active_verified_user, fastapi_users, get_oauth_provider from app.services.user_service import auth_backend, current_active_verified_user, fastapi_users, get_oauth_provider, UserManager, get_jwt_strategy
from fastapi import FastAPI
import sentry_sdk
from fastapi_users.db import SQLAlchemyUserDatabase
from app.core.db import async_session_maker
sentry_sdk.init(
dsn=os.getenv("SENTRY_DSN"),
send_default_pii=True,
)
fastApi = FastAPI() fastApi = FastAPI()
app = fastApi
# CORS for frontend dev server # CORS for frontend dev server
fastApi.add_middleware( fastApi.add_middleware(
@@ -60,6 +75,7 @@ fastApi.include_router(
auth_backend, auth_backend,
"SECRET", "SECRET",
associate_by_email=True, associate_by_email=True,
redirect_url=os.getenv("FRONTEND_DOMAIN_SCHEME", "http://localhost:3000") + "/auth/mojeid/callback",
), ),
prefix="/auth/mojeid", prefix="/auth/mojeid",
tags=["auth"], tags=["auth"],
@@ -71,11 +87,13 @@ fastApi.include_router(
auth_backend, auth_backend,
"SECRET", "SECRET",
associate_by_email=True, associate_by_email=True,
redirect_url=os.getenv("FRONTEND_DOMAIN_SCHEME", "http://localhost:3000") + "/auth/bankid/callback",
), ),
prefix="/auth/bankid", prefix="/auth/bankid",
tags=["auth"], tags=["auth"],
) )
fastApi.include_router(csas_router)
# Liveness/root endpoint # Liveness/root endpoint
@fastApi.get("/", include_in_schema=False) @fastApi.get("/", include_in_schema=False)
@@ -86,3 +104,21 @@ async def root():
@fastApi.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}!"}
@fastApi.get("/sentry-debug")
async def trigger_error():
division_by_zero = 1 / 0
@fastApi.get("/debug/scrape/csas/all", tags=["debug"])
async def debug_scrape_csas_all():
logging.info("[Debug] Queueing CSAS scrape for all users via HTTP endpoint (Celery)")
task = load_all_transactions.delay()
return {"status": "queued", "action": "csas_scrape_all", "task_id": getattr(task, 'id', None)}
@fastApi.post("/debug/scrape/csas/{user_id}", tags=["debug"])
async def debug_scrape_csas_user(user_id: str, user: User = Depends(current_active_verified_user)):
logging.info("[Debug] Queueing CSAS scrape for single user via HTTP endpoint (Celery) | user_id=%s", user_id)
task = load_transactions.delay(user_id)
return {"status": "queued", "action": "csas_scrape_single", "user_id": user_id, "task_id": getattr(task, 'id', None)}

View File

@@ -1,6 +1,8 @@
from sqlalchemy import Column, String from sqlalchemy import Column, String
from sqlalchemy.orm import relationship, mapped_column, Mapped from sqlalchemy.orm import relationship, mapped_column, Mapped
from fastapi_users.db import SQLAlchemyBaseUserTableUUID, SQLAlchemyBaseOAuthAccountTableUUID from fastapi_users.db import SQLAlchemyBaseUserTableUUID, SQLAlchemyBaseOAuthAccountTableUUID
from sqlalchemy.sql.sqltypes import JSON
from app.core.base import Base from app.core.base import Base
@@ -13,6 +15,7 @@ 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") oauth_accounts = relationship("OAuthAccount", lazy="joined")
config = Column(JSON, default={})
# Relationship # Relationship
transactions = relationship("Transaction", back_populates="user") transactions = relationship("Transaction", back_populates="user")

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,121 @@
import json
import logging
from os.path import dirname, join
from uuid import UUID
import httpx
from sqlalchemy import select
from app.core.db import async_session_maker
from app.models.user import User
logger = logging.getLogger(__name__)
# Reuse CSAS mTLS certs used by OAuth profile calls
OAUTH_DIR = join(dirname(__file__), "..", "oauth")
CERTS = (
join(OAUTH_DIR, "public_key.pem"),
join(OAUTH_DIR, "private_key.key"),
)
async def aload_ceska_sporitelna_transactions(user_id: str) -> None:
"""
Async entry point to load Česká spořitelna transactions for a single user.
Validates the user_id and performs a minimal placeholder action.
"""
try:
uid = UUID(str(user_id))
except Exception:
logger.error("Invalid user_id provided to bank_scraper (async): %r", user_id)
return
await _aload_ceska_sporitelna_transactions(uid)
async def aload_all_ceska_sporitelna_transactions() -> None:
"""
Async entry point to load Česká spořitelna transactions for all users.
"""
async with async_session_maker() as session:
result = await session.execute(select(User))
users = result.unique().scalars().all()
logger.info("[BankScraper] Starting CSAS scrape for all users | count=%d", len(users))
processed = 0
for user in users:
try:
await _aload_ceska_sporitelna_transactions(user.id)
processed += 1
except Exception:
logger.exception("[BankScraper] Error scraping for user id=%s email=%s", user.id,
getattr(user, 'email', None))
logger.info("[BankScraper] Finished CSAS scrape for all users | processed=%d", processed)
async def _aload_ceska_sporitelna_transactions(user_id: UUID) -> None:
async with async_session_maker() as session:
result = await session.execute(select(User).where(User.id == user_id))
user: User = result.unique().scalar_one_or_none()
if user is None:
logger.warning("User not found for id=%s", user_id)
return
cfg = user.config or {}
if "csas" not in cfg:
return
cfg = json.loads(cfg["csas"])
if "access_token" not in cfg:
return
accounts = []
try:
async with httpx.AsyncClient(cert=CERTS, timeout=httpx.Timeout(20.0)) as client:
response = await client.get(
"https://webapi.developers.erstegroup.com/api/csas/sandbox/v4/account-information/my/accounts?size=10&page=0&sort=iban&order=desc",
headers={
"Authorization": f"Bearer {cfg['access_token']}",
"WEB-API-key": "09fdc637-3c57-4242-95f2-c2205a2438f3",
"user-involved": "false",
},
)
if response.status_code != httpx.codes.OK:
return
for account in response.json()["accounts"]:
accounts.append(account)
except (httpx.HTTPError,) as e:
logger.exception("[BankScraper] HTTP error during CSAS request | user_id=%s", user_id)
return
for account in accounts:
id = account["id"]
url = f"https://webapi.developers.erstegroup.com/api/csas/sandbox/v4/account-information/my/accounts/{id}/transactions?size=100&page=0&sort=bookingdate&order=desc"
async with httpx.AsyncClient(cert=CERTS) as client:
response = await client.get(
url,
headers={
"Authorization": f"Bearer {cfg['access_token']}",
"WEB-API-key": "09fdc637-3c57-4242-95f2-c2205a2438f3",
"user-involved": "false",
},
)
if response.status_code != httpx.codes.OK:
continue
# Placeholder: just print the account transactions
transactions = response.json()["transactions"]
pass
for transaction in transactions:
#parse and store transaction to database
#create Transaction object and save to DB
#obj =
pass
pass

View File

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

View File

@@ -1,7 +1,10 @@
import logging import logging
import asyncio
from celery import shared_task from celery import shared_task
import app.services.bank_scraper
logger = logging.getLogger("celery_tasks") logger = logging.getLogger("celery_tasks")
if not logger.handlers: if not logger.handlers:
_h = logging.StreamHandler() _h = logging.StreamHandler()
@@ -9,6 +12,72 @@ if not logger.handlers:
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
def run_coro(coro) -> None:
"""Run an async coroutine in a fresh event loop without using run_until_complete.
Primary strategy runs in a new loop in the current thread. If that fails due to
debugger patches (e.g., Bad file descriptor from pydevd_nest_asyncio), fall back
to running in a dedicated thread with its own event loop.
"""
import threading
def _cleanup_loop(loop):
try:
pending = [t for t in asyncio.all_tasks(loop) if not t.done()]
for t in pending:
t.cancel()
if pending:
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
except Exception:
pass
finally:
try:
loop.close()
finally:
asyncio.set_event_loop(None)
# First attempt: Run in current thread with a fresh event loop
try:
loop = asyncio.get_event_loop_policy().new_event_loop()
try:
asyncio.set_event_loop(loop)
task = loop.create_task(coro)
task.add_done_callback(lambda _t: loop.stop())
loop.run_forever()
exc = task.exception()
if exc:
raise exc
return
finally:
_cleanup_loop(loop)
except OSError as e:
logger.warning("run_coro primary strategy failed (%s). Falling back to thread runner.", e)
except Exception:
# For any other unexpected errors, try thread fallback as well
logger.exception("run_coro primary strategy raised; attempting thread fallback")
# Fallback: Run in a dedicated thread with its own event loop
error = {"exc": None}
def _thread_target():
loop = asyncio.new_event_loop()
try:
asyncio.set_event_loop(loop)
task = loop.create_task(coro)
task.add_done_callback(lambda _t: loop.stop())
loop.run_forever()
exc = task.exception()
if exc:
error["exc"] = exc
finally:
_cleanup_loop(loop)
th = threading.Thread(target=_thread_target, name="celery-async-runner", daemon=True)
th.start()
th.join()
if error["exc"] is not None:
raise error["exc"]
@shared_task(name="workers.send_email") @shared_task(name="workers.send_email")
def send_email(to: str, subject: str, body: str) -> None: def send_email(to: str, subject: str, body: str) -> None:
if not (to and subject and body): if not (to and subject and body):
@@ -17,3 +86,22 @@ def send_email(to: str, subject: str, body: str) -> None:
# Placeholder for real email sending logic # Placeholder for real email sending logic
logger.info("[Celery] Email sent | to=%s | subject=%s | body_len=%d", to, subject, len(body)) logger.info("[Celery] Email sent | to=%s | subject=%s | body_len=%d", to, subject, len(body))
@shared_task(name="workers.load_transactions")
def load_transactions(user_id: str) -> None:
if not user_id:
logger.error("Load transactions task missing user_id.")
return
run_coro(app.services.bank_scraper.aload_ceska_sporitelna_transactions(user_id))
# Placeholder for real transaction loading logic
logger.info("[Celery] Transactions loaded for user_id=%s", user_id)
@shared_task(name="workers.load_all_transactions")
def load_all_transactions() -> None:
logger.info("[Celery] Starting load_all_transactions")
run_coro(app.services.bank_scraper.aload_all_ceska_sporitelna_transactions())
logger.info("[Celery] Finished load_all_transactions")

View File

@@ -0,0 +1,2 @@
[tool.pytest.ini_options]
pythonpath = "."

View File

@@ -50,6 +50,7 @@ python-dateutil==2.9.0.post0
python-dotenv==1.1.1 python-dotenv==1.1.1
python-multipart==0.0.20 python-multipart==0.0.20
PyYAML==6.0.2 PyYAML==6.0.2
sentry-sdk==2.42.0
six==1.17.0 six==1.17.0
sniffio==1.3.1 sniffio==1.3.1
SQLAlchemy==2.0.43 SQLAlchemy==2.0.43
@@ -58,6 +59,7 @@ tomli==2.2.1
typing-inspection==0.4.1 typing-inspection==0.4.1
typing_extensions==4.15.0 typing_extensions==4.15.0
tzdata==2025.2 tzdata==2025.2
urllib3==2.5.0
uvicorn==0.37.0 uvicorn==0.37.0
uvloop==0.21.0 uvloop==0.21.0
vine==5.1.0 vine==5.1.0

View File

@@ -0,0 +1,22 @@
import sys
import types
import pytest
from fastapi.testclient import TestClient
# Stub sentry_sdk to avoid optional dependency issues during import of app
stub = types.ModuleType("sentry_sdk")
stub.init = lambda *args, **kwargs: None
sys.modules.setdefault("sentry_sdk", stub)
# Import the FastAPI application
from app.app import fastApi as app # noqa: E402
@pytest.fixture(scope="session")
def fastapi_app():
return app
@pytest.fixture(scope="session")
def client(fastapi_app):
return TestClient(fastapi_app, raise_server_exceptions=True)

View File

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

View File

@@ -0,0 +1,18 @@
from fastapi import status
import pytest
def test_root_ok(client):
resp = client.get("/")
assert resp.status_code == status.HTTP_200_OK
assert resp.json() == {"status": "ok"}
def test_authenticated_route_requires_auth(client):
resp = client.get("/authenticated-route")
assert resp.status_code in (status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN)
def test_sentry_debug_raises_exception(client):
with pytest.raises(ZeroDivisionError):
client.get("/sentry-debug")

View File

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

View File

@@ -1,54 +0,0 @@
Thank you for installing myapp-chart.
This chart packages all Kubernetes manifests from the original deployment directory and parameterizes environment, database name (with optional PR suffix), image, and domain for external access.
Namespaces per developer (important):
- Install each developer's environment into their own namespace using Helm's -n/--namespace flag.
- No hardcoded namespace is used in templates; resources are created in .Release.Namespace.
- Example namespaces: dev-alice, dev-bob, pr-123, etc.
Key values:
- deployment -> used as Database CR name and DB username (MARIADB_DB and MARIADB_USER)
- image.repository/tag or image.digest -> container image
- domain -> public FQDN used by TunnelBinding (required to expose app)
- app/worker names, replicas, ports
Examples:
- Dev install (Alice):
helm upgrade --install myapp ./7project/charts/myapp-chart \
-n dev-alice --create-namespace \
-f values-dev.yaml \
--set domain=alice.demo.example.com \
--set-string rabbitmq.password="$RABBITMQ_PASSWORD" \
--set-string database.password="$DB_PASSWORD"
- Dev install (Bob):
helm upgrade --install myapp ./7project/charts/myapp-chart \
-n dev-bob --create-namespace \
-f values-dev.yaml \
--set domain=bob.demo.example.com
- Prod install (different cleanupPolicy):
helm upgrade --install myapp ./7project/charts/myapp-chart \
-n prod --create-namespace \
-f values-prod.yaml \
--set domain=app.example.com
- PR (preview) install with DB name containing PR number (also its own namespace):
PR=123
helm upgrade --install myapp-pr-$PR ./7project/charts/myapp-chart \
-n pr-$PR --create-namespace \
-f values-dev.yaml \
--set prNumber=$PR \
--set deployment=preview-$PR \
--set domain=pr-$PR.example.com
- Use a custom deployment identifier to suffix DB name, DB username and Secret name:
helm upgrade --install myapp ./7project/charts/myapp-chart \
-n dev-alice --create-namespace \
-f values-dev.yaml \
--set deployment=alice \
--set domain=alice.demo.example.com
Render locally (dry run):
helm template ./7project/charts/myapp-chart -f values-dev.yaml --set prNumber=456 --set deployment=test --set domain=demo.example.com --namespace dev-test | sed -n '/kind: Database/,$p' | head -n 30

View File

@@ -20,7 +20,7 @@ spec:
securityContext: securityContext:
allowPrivilegeEscalation: false allowPrivilegeEscalation: false
capabilities: capabilities:
drop: ["ALL"] drop: [ "ALL" ]
ports: ports:
- containerPort: {{ .Values.app.port }} - containerPort: {{ .Values.app.port }}
env: env:
@@ -29,21 +29,27 @@ spec:
- name: MARIADB_PORT - name: MARIADB_PORT
value: '3306' value: '3306'
- name: MARIADB_DB - name: MARIADB_DB
value: {{ required "Set .Values.deployment" .Values.deployment | quote }} valueFrom:
secretKeyRef:
name: prod
key: MARIADB_DB
- name: MARIADB_USER - name: MARIADB_USER
value: {{ required "Set .Values.deployment" .Values.deployment | quote }} valueFrom:
secretKeyRef:
name: prod
key: MARIADB_USER
- name: MARIADB_PASSWORD - name: MARIADB_PASSWORD
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: {{ required "Set .Values.database.secretName" .Values.database.secretName }} name: prod
key: password key: MARIADB_PASSWORD
- name: RABBITMQ_USERNAME - name: RABBITMQ_USERNAME
value: {{ .Values.rabbitmq.username | quote }} value: {{ .Values.rabbitmq.username | quote }}
- name: RABBITMQ_PASSWORD - name: RABBITMQ_PASSWORD
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: {{ printf "%s-user-credentials" (.Values.rabbitmq.username | default "app-user") }} name: prod
key: password key: RABBITMQ_PASSWORD
- name: RABBITMQ_HOST - name: RABBITMQ_HOST
value: {{ printf "%s.%s.svc.cluster.local" "rabbitmq-cluster" .Release.Namespace | quote }} value: {{ printf "%s.%s.svc.cluster.local" "rabbitmq-cluster" .Release.Namespace | quote }}
- name: RABBITMQ_PORT - name: RABBITMQ_PORT
@@ -52,6 +58,49 @@ spec:
value: {{ .Values.rabbitmq.vhost | default "/" | quote }} value: {{ .Values.rabbitmq.vhost | default "/" | quote }}
- name: MAIL_QUEUE - name: MAIL_QUEUE
value: {{ .Values.worker.mailQueueName | default "mail_queue" | quote }} value: {{ .Values.worker.mailQueueName | default "mail_queue" | quote }}
- name: MOJEID_CLIENT_ID
valueFrom:
secretKeyRef:
name: prod
key: MOJEID_CLIENT_ID
- name: MOJEID_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: prod
key: MOJEID_CLIENT_SECRET
- name: BANKID_CLIENT_ID
valueFrom:
secretKeyRef:
name: prod
key: BANKID_CLIENT_ID
- name: BANKID_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: prod
key: BANKID_CLIENT_SECRET
- name: CSAS_CLIENT_ID
valueFrom:
secretKeyRef:
name: prod
key: CSAS_CLIENT_ID
- name: CSAS_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: prod
key: CSAS_CLIENT_SECRET
- name: DOMAIN
value: {{ required "Set .Values.domain" .Values.domain | quote }}
- name: DOMAIN_SCHEME
value: {{ required "Set .Values.domain_scheme" .Values.domain_scheme | quote }}
- name: FRONTEND_DOMAIN
value: {{ required "Set .Values.frontend_domain" .Values.frontend_domain | quote }}
- name: FRONTEND_DOMAIN_SCHEME
value: {{ required "Set .Values.frontend_domain_scheme" .Values.frontend_domain_scheme | quote }}
- name: SENTRY_DSN
valueFrom:
secretKeyRef:
name: prod
key: SENTRY_DSN
livenessProbe: livenessProbe:
httpGet: httpGet:
path: / path: /

View File

@@ -0,0 +1,20 @@
apiVersion: v1
kind: Secret
metadata:
name: prod
type: Opaque
stringData:
MOJEID_CLIENT_ID: {{ .Values.oauth.mojeid.clientId | quote }}
MOJEID_CLIENT_SECRET: {{ .Values.oauth.mojeid.clientSecret | quote }}
BANKID_CLIENT_ID: {{ .Values.oauth.bankid.clientId | quote }}
BANKID_CLIENT_SECRET: {{ .Values.oauth.bankid.clientSecret | quote }}
CSAS_CLIENT_ID: {{ .Values.oauth.csas.clientId | quote }}
CSAS_CLIENT_SECRET: {{ .Values.oauth.csas.clientSecret | quote }}
# Database credentials
MARIADB_DB: {{ required "Set .Values.deployment" .Values.deployment | quote }}
MARIADB_USER: {{ required "Set .Values.deployment" .Values.deployment | quote }}
MARIADB_PASSWORD: {{ .Values.database.password | default "" | quote }}
# RabbitMQ credentials
RABBITMQ_PASSWORD: {{ .Values.rabbitmq.password | default "" | quote }}
RABBITMQ_USERNAME: {{ .Values.rabbitmq.username | quote }}
SENTRY_DSN: {{ .Values.sentry_dsn | quote }}

View File

@@ -31,13 +31,32 @@ spec:
- --loglevel - --loglevel
- INFO - INFO
env: env:
- name: MARIADB_HOST
value: "mariadb-repl-maxscale-internal.mariadb-operator.svc.cluster.local"
- name: MARIADB_PORT
value: '3306'
- name: MARIADB_DB
valueFrom:
secretKeyRef:
name: prod
key: MARIADB_DB
- name: MARIADB_USER
valueFrom:
secretKeyRef:
name: prod
key: MARIADB_USER
- name: MARIADB_PASSWORD
valueFrom:
secretKeyRef:
name: prod
key: MARIADB_PASSWORD
- name: RABBITMQ_USERNAME - name: RABBITMQ_USERNAME
value: {{ .Values.rabbitmq.username | quote }} value: {{ .Values.rabbitmq.username | quote }}
- name: RABBITMQ_PASSWORD - name: RABBITMQ_PASSWORD
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: {{ printf "%s-user-credentials" (.Values.rabbitmq.username | default "app-user") }} name: prod
key: password key: RABBITMQ_PASSWORD
- name: RABBITMQ_HOST - name: RABBITMQ_HOST
value: {{ printf "%s.%s.svc.cluster.local" "rabbitmq-cluster" .Release.Namespace | quote }} value: {{ printf "%s.%s.svc.cluster.local" "rabbitmq-cluster" .Release.Namespace | quote }}
- name: RABBITMQ_PORT - name: RABBITMQ_PORT
@@ -46,3 +65,18 @@ spec:
value: {{ .Values.rabbitmq.vhost | default "/" | quote }} value: {{ .Values.rabbitmq.vhost | default "/" | quote }}
- name: MAIL_QUEUE - name: MAIL_QUEUE
value: {{ .Values.worker.mailQueueName | default "mail_queue" | quote }} value: {{ .Values.worker.mailQueueName | default "mail_queue" | quote }}
- name: SENTRY_DSN
valueFrom:
secretKeyRef:
name: prod
key: SENTRY_DSN
- name: CSAS_CLIENT_ID
valueFrom:
secretKeyRef:
name: prod
key: CSAS_CLIENT_ID
- name: CSAS_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: prod
key: CSAS_CLIENT_SECRET

View File

@@ -11,6 +11,12 @@ deployment: ""
# Public domain to expose the app under (used by TunnelBinding fqdn) # Public domain to expose the app under (used by TunnelBinding fqdn)
# Set at install time: --set domain=example.com # Set at install time: --set domain=example.com
domain: "" domain: ""
domain_scheme: ""
frontend_domain: ""
frontend_domain_scheme: ""
sentry_dsn: ""
image: image:
repository: lukastrkan/cc-app-demo repository: lukastrkan/cc-app-demo
@@ -33,6 +39,17 @@ worker:
service: service:
port: 80 port: 80
oauth:
bankid:
clientId: ""
clientSecret: ""
mojeid:
clientId: ""
clientSecret: ""
csas:
clientId: ""
clientSecret: ""
rabbitmq: rabbitmq:
create: true create: true
replicas: 1 replicas: 1

View File

@@ -1,20 +0,0 @@
apiVersion: k8s.mariadb.com/v1alpha1
kind: Grant
metadata:
name: grant
spec:
mariaDbRef:
name: mariadb-repl
namespace: mariadb-operator
privileges:
- "ALL PRIVILEGES"
database: "app-demo-database"
table: "*"
username: "app-demo-user"
grantOption: true
host: "%"
# Delete the resource in the database whenever the CR gets deleted.
# Alternatively, you can specify Skip in order to omit deletion.
cleanupPolicy: Skip
requeueInterval: 10h
retryInterval: 30s

View File

@@ -1,7 +0,0 @@
apiVersion: v1
kind: Secret
metadata:
name: app-demo-database-secret
type: kubernetes.io/basic-auth
stringData:
password: "strongpassword"

View File

@@ -1,20 +0,0 @@
apiVersion: k8s.mariadb.com/v1alpha1
kind: User
metadata:
name: app-demo-user
spec:
# If you want the user to be created with a different name than the resource name
# name: user-custom
mariaDbRef:
name: mariadb-repl
namespace: mariadb-operator
passwordSecretKeyRef:
name: app-demo-database-secret
key: password
maxUserConnections: 20
host: "%"
# Delete the resource in the database whenever the CR gets deleted.
# Alternatively, you can specify Skip in order to omit deletion.
cleanupPolicy: Skip
requeueInterval: 10h
retryInterval: 30s

View File

@@ -1,15 +0,0 @@
apiVersion: k8s.mariadb.com/v1alpha1
kind: Database
metadata:
name: app-demo-database
spec:
mariaDbRef:
name: mariadb-repl
namespace: mariadb-operator
characterSet: utf8
collate: utf8_general_ci
# Delete the resource in the database whenever the CR gets deleted.
# Alternatively, you can specify Skip in order to omit deletion.
cleanupPolicy: Skip
requeueInterval: 10h
retryInterval: 30s

View File

@@ -1,48 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: app-demo
spec:
replicas: 3
revisionHistoryLimit: 3
selector:
matchLabels:
app: app-demo
template:
metadata:
labels:
app: app-demo
spec:
containers:
- image: lukastrkan/cc-app-demo@sha256:75634b4d97282b6b8424fe17767c81adf44af5f7359c1d25883073b5629b3e05
name: app-demo
ports:
- containerPort: 8000
env:
- name: MARIADB_HOST
value: mariadb-repl.mariadb-operator.svc.cluster.local
- name: MARIADB_PORT
value: '3306'
- name: MARIADB_DB
value: app-demo-database
- name: MARIADB_USER
value: app-demo-user
- name: MARIADB_PASSWORD
valueFrom:
secretKeyRef:
name: app-demo-database-secret
key: password
livenessProbe:
httpGet:
path: /
port: 8000
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /
port: 8000
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 3

View File

@@ -1,10 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: app-demo
spec:
ports:
- port: 80
targetPort: 8000
selector:
app: app-demo

View File

@@ -1,41 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: app-demo-worker
spec:
replicas: 3
revisionHistoryLimit: 3
selector:
matchLabels:
app: app-demo-worker
template:
metadata:
labels:
app: app-demo-worker
spec:
containers:
- image: lukastrkan/cc-app-demo@sha256:75634b4d97282b6b8424fe17767c81adf44af5f7359c1d25883073b5629b3e05
name: app-demo-worker
command:
- celery
- -A
- app.celery_app
- worker
- -Q
- $(MAIL_QUEUE)
- --loglevel
- INFO
env:
- name: RABBITMQ_USERNAME
value: demo-app
- name: RABBITMQ_PASSWORD
valueFrom:
secretKeyRef:
name: demo-app-user-credentials
key: password
- name: RABBITMQ_HOST
value: rabbitmq.rabbitmq.svc.cluster.local
- name: RABBITMQ_PORT
value: '5672'
- name: RABBITMQ_VHOST
value: "/"

View File

@@ -1,14 +0,0 @@
apiVersion: networking.cfargotunnel.com/v1alpha1
kind: TunnelBinding
metadata:
name: guestbook-tunnel-binding
namespace: group-project
subjects:
- name: app-server
spec:
target: http://app-demo.group-project.svc.cluster.local
fqdn: demo.ltrk.cz
noTlsVerify: true
tunnelRef:
kind: ClusterTunnel
name: cluster-tunnel

View File

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

View File

@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { type Category, type Transaction, createTransaction, getCategories, getTransactions } from '../api'; import { type Category, type Transaction, createTransaction, getCategories, getTransactions } from '../api';
import AccountPage from './AccountPage'; import AccountPage from './AccountPage';
import AppearancePage from './AppearancePage'; import AppearancePage from './AppearancePage';
import { BACKEND_URL } from '../config';
function formatAmount(n: number) { function formatAmount(n: number) {
return new Intl.NumberFormat(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n); return new Intl.NumberFormat(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n);
@@ -14,6 +15,27 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Start CSAS (George) OAuth after login
async function startOauthCsas() {
const base = BACKEND_URL.replace(/\/$/, '');
const url = `${base}/auth/csas/authorize`;
try {
const token = localStorage.getItem('token');
const res = await fetch(url, {
credentials: 'include',
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
const data = await res.json();
if (data && typeof data.authorization_url === 'string') {
window.location.assign(data.authorization_url);
} else {
alert('Cannot start CSAS OAuth.');
}
} catch (e) {
alert('Cannot start CSAS OAuth.');
}
}
// New transaction form state // New transaction form state
const [amount, setAmount] = useState<string>(''); const [amount, setAmount] = useState<string>('');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
@@ -97,6 +119,12 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
<main className="page space-y"> <main className="page space-y">
{current === 'home' && ( {current === 'home' && (
<> <>
<section className="card">
<h3>Bank connections</h3>
<p className="muted">Connect your CSAS (George) account.</p>
<button className="btn" onClick={startOauthCsas}>Connect CSAS (George)</button>
</section>
<section className="card"> <section className="card">
<h3>Add Transaction</h3> <h3>Add Transaction</h3>
<form onSubmit={handleCreate} className="form-row"> <form onSubmit={handleCreate} className="form-row">

View File

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

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

@@ -1,4 +1,4 @@
# Project Report # Personal finance tracker
> **Instructions**: > **Instructions**:
> This template provides the structure for your project report. > This template provides the structure for your project report.
@@ -7,126 +7,211 @@
## Project Overview ## Project Overview
**Project Name**: [Your project name] **Project Name**: Personal Finance Tracker
**Group Members**: **Group Members**:
- Student number, Name, GitHub username - 289229, Lukáš Trkan, lukastrkan
- Student number, Name, GitHub username - 289258, Dejan Ribarovski, derib2613, ribardej
- Student number, Name, GitHub username
**Brief Description**: **Brief Description**:
[2-3 sentences describing what your application does and its main purpose] 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 ## 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 ### High-Level Architecture
[Describe the overall system architecture. Consider including a diagram using mermaid or linking to an image]
```mermaid ```mermaid
graph TD flowchart LR
A[Component A] --> B[Component B] proc_queue[Message Queue] --> proc_queue_worker[Worker Service]
B --> C[Component C] 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 ### Components
- **Component 1**: [Description of what this component does] - 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.
- **Component 2**: [Description of what this component does] - 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.
- **Component 3**: [Description of what this component does] - 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 ### Technologies Used
- **Backend**: [e.g., Go, Node.js, Python] - Backend: Python, FastAPI, FastAPI Users, SQLAlchemy, Pydantic, Alembic, Celery
- **Database**: [e.g., PostgreSQL, MongoDB, Redis] - Frontend: React, TypeScript, Vite
- **Cloud Services**: [e.g., AWS EC2, Google Cloud Run, Azure Functions] - Database: PostgreSQL
- **Container Orchestration**: [e.g., Docker, Kubernetes] - Messaging: RabbitMQ
- **Other**: [List other significant technologies] - Cache: Redis
- Containerization/Orchestration: Docker, Docker Compose (dev), Kubernetes, Helm
- IaC/Platform: OpenTofu (Terraform), Argo CD, cert-manager, MetalLB, Cloudflare Tunnel, Prometheus
## Prerequisites ## Prerequisites
### System Requirements ### System Requirements
- Operating System: [e.g., Linux, macOS, Windows] - Operating System: Linux, macOS, or Windows
- Minimum RAM: [e.g., 8GB] - Minimum RAM: 4 GB (8 GB recommended for running backend, frontend, and database together)
- Storage: [e.g., 10GB free space] - Storage: 2 GB free (Docker images may require additional space)
### Required Software ### Required Software
- [Software 1] (version X.X or higher) - Docker Desktop or Docker Engine 24+
- [Software 2] (version X.X or higher) - Docker Compose v2+
- [etc.] - 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)
### Dependencies ### Environment Variables (common)
```bash - Backend: SECRET, FRONTEND_URL, BACKEND_URL, DATABASE_URL, RABBITMQ_URL, REDIS_URL
# List key dependencies that need to be installed - OAuth vars (Backend): MOJEID_CLIENT_ID/SECRET, BANKID_CLIENT_ID/SECRET (optional)
# For example: - Frontend: VITE_BACKEND_URL
# Docker Engine 20.10+
# Node.js 18+ ### Dependencies (key libraries)
# Go 1.25+ 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 ## Build Instructions
### 1. Clone the Repository You can run the project with Docker Compose (recommended for local development) or run services manually.
### 1) Clone the Repository
```bash ```bash
git clone [your-repository-url] git clone https://github.com/dat515-2025/Group-8.git
cd [repository-name] cd 7project
``` ```
### 2. Install Dependencies ### 2) Install dependencies
Backend
```bash ```bash
# Provide step-by-step commands # In 7project/backend
# For example: python3.12 -m venv .venv
# npm install source .venv/bin/activate # Windows: .venv\Scripts\activate
# go mod download pip install -r requirements.txt
``` ```
Frontend
### 3. Build the Application
```bash ```bash
# Provide exact build commands # In 7project/frontend
# For example: npm install
# make build
# docker build -t myapp .
``` ```
### 4. Configuration ### 3) Manual Local Run
Backend
```bash ```bash
# Any configuration steps needed # From the 7project/ directory
# Environment variables to set docker compose up --build
# Configuration files to create # 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 ## Deployment Instructions
### Local Deployment ### 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 ```bash
# Step-by-step commands for local deployment cd tofu
# For example: # copy and edit variables
# docker-compose up -d cp terraform.tfvars.example terraform.tfvars
# kubectl apply -f manifests/ # authenticate to your cluster/cloud as needed, then:
tofu init
tofu plan
tofu apply
``` ```
### Cloud Deployment 2) Deploy the app using Helm
```bash ```bash
# Commands for cloud deployment # Set the namespace
# Include any cloud-specific setup 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 ### Verification
```bash ```bash
# Commands to verify deployment worked # Check pods
# How to check if services are running kubectl -n myapp get pods
# Example health check endpoints
# 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 ## Testing Instructions
@@ -156,19 +241,38 @@ cd [repository-name]
## Usage Examples ## Usage Examples
### Basic Usage All endpoints are documented at OpenAPI: http://127.0.0.1:8000/docs
### Auth: Register and Login (JWT)
```bash ```bash
# Examples of how to use the application # Register
# Common commands or API calls curl -X POST http://127.0.0.1:8000/auth/register \
# Sample data or test scenarios -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
``` ```
### Advanced Features ### Frontend
```bash - Start with: npm run dev in 7project/frontend
# Examples showcasing advanced functionality - 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.
--- ---
@@ -215,18 +319,18 @@ cd [repository-name]
> This information is used for individual grading. > This information is used for individual grading.
> Link to the specific commit on GitHub for each contribution. > Link to the specific commit on GitHub for each contribution.
| Task/Component | Assigned To | Status | Time Spent | Difficulty | Notes | | Task/Component | Assigned To | Status | Time Spent | Difficulty | Notes |
| ------------------------------------------------------------------- | ----------- | ------------- | ---------- | ---------- | ----------- | |-----------------------------------------------------------------------|-------------| ------------- |----------------|------------| ----------- |
| Project Setup & Repository | [Name] | ✅ Complete | [X hours] | Medium | [Any notes] | | [Project Setup & Repository](https://github.com/dat515-2025/Group-8#) | Lukas | ✅ Complete | [X hours] | Medium | [Any notes] |
| [Design Document](https://github.com/dat515-2025/group-name) | [Name] | ✅ Complete | [X hours] | Easy | [Any notes] | | [Design Document](https://github.com/dat515-2025/Group-8/blob/main/6design/design.md) | Both | ✅ Complete | 2 Hours | Easy | [Any notes] |
| [Backend API Development](https://github.com/dat515-2025/group-name) | [Name] | ✅ Complete | [X hours] | Hard | [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-name) | [Name] | ✅ Complete | [X 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-name) | [Name] | 🔄 In Progress | [X hours] | Medium | [Any notes] | | [Frontend Development](https://github.com/dat515-2025/Group-8/tree/main/7project/frontend) | Dejan | 🔄 In Progress | 7 hours so far | Medium | [Any notes] |
| [Docker Configuration](https://github.com/dat515-2025/group-name) | [Name] | ✅ Complete | [X hours] | Easy | [Any notes] | | [Docker Configuration](https://github.com/dat515-2025/Group-8/blob/main/7project/compose.yml) | Lukas | ✅ Complete | [X hours] | Easy | [Any notes] |
| [Cloud Deployment](https://github.com/dat515-2025/group-name) | [Name] | ✅ Complete | [X hours] | Hard | [Any notes] | | [Cloud Deployment](https://github.com/dat515-2025/Group-8/blob/main/7project/deployment/app-demo-deployment.yaml) | Lukas | ✅ Complete | [X hours] | Hard | [Any notes] |
| [Testing Implementation](https://github.com/dat515-2025/group-name) | [Name] | ⏳ Pending | [X hours] | Medium | [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) | [Name] | ✅ Complete | [X hours] | Easy | [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) | [Name] | ✅ Complete | [X hours] | Medium | [Any notes] | | [Presentation Video](https://github.com/dat515-2025/group-name) | Both | ❌ Not Started | [X hours] | Medium | [Any notes] |
**Legend**: ✅ Complete | 🔄 In Progress | ⏳ Pending | ❌ Not Started **Legend**: ✅ Complete | 🔄 In Progress | ⏳ Pending | ❌ Not Started
@@ -244,25 +348,16 @@ cd [repository-name]
| [Date] | Documentation | [X.X] | Updated README and design doc | | [Date] | Documentation | [X.X] | Updated README and design doc |
| **Total** | | **[XX.X]** | | | **Total** | | **[XX.X]** | |
### [Team Member 2 Name] ### Dejan
| Date | Activity | Hours | Description | | Date | Activity | Hours | Description |
| --------- | -------------------- | ---------- | ----------------------------------------- | |-------------|----------------------|--------|--------------------------------|
| [Date] | Frontend Development | [X.X] | Created user interface mockups | | 25.9. | Design | 1.5 | 6design |
| [Date] | Integration | [X.X] | Connected frontend to backend API | | 9-11.10. | Backend APIs | 10 | Implemented Backend APIs |
| [Date] | Deployment | [X.X] | Docker configuration and cloud deployment | | 13-15.10. | Frontend Development | 6.5 | Created user interface mockups |
| [Date] | Testing | [X.X] | End-to-end testing | | Continually | Documantation | 3 | Documenting the dev process |
| **Total** | | **[XX.X]** | | | **Total** | | **21** | |
### [Team Member 3 Name] (if applicable)
| Date | Activity | Hours | Description |
| --------- | ------------------------ | ---------- | -------------------------------- |
| [Date] | Database Design | [X.X] | Schema design and implementation |
| [Date] | Cloud Configuration | [X.X] | AWS/GCP setup and configuration |
| [Date] | Performance Optimization | [X.X] | Caching and query optimization |
| [Date] | Monitoring | [X.X] | Logging and monitoring setup |
| **Total** | | **[XX.X]** | |
### Group Total: [XXX.X] hours ### Group Total: [XXX.X] hours
@@ -292,11 +387,8 @@ cd [repository-name]
[Personal reflection on growth, challenges, and learning] [Personal reflection on growth, challenges, and learning]
#### [Team Member 3 Name] (if applicable)
[Personal reflection on growth, challenges, and learning]
--- ---
**Report Completion Date**: [Date] **Report Completion Date**: [Date]
**Last Updated**: [Date] **Last Updated**: 15.10.2025

72
requirements.txt Normal file
View File

@@ -0,0 +1,72 @@
aio-pika==9.5.6
aiormq==6.8.1
aiosqlite==0.21.0
alembic==1.16.5
amqp==5.3.1
annotated-types==0.7.0
anyio==4.11.0
argon2-cffi==23.1.0
argon2-cffi-bindings==25.1.0
asyncmy==0.2.9
bcrypt==4.3.0
billiard==4.2.2
celery==5.5.3
certifi==2025.10.5
cffi==2.0.0
click==8.1.8
click-didyoumean==0.3.1
click-plugins==1.1.1.2
click-repl==0.3.0
cryptography==46.0.1
dnspython==2.7.0
email_validator==2.2.0
exceptiongroup==1.3.0
fastapi==0.117.1
fastapi-users==14.0.1
fastapi-users-db-sqlalchemy==7.0.0
greenlet==3.2.4
h11==0.16.0
httpcore==1.0.9
httptools==0.6.4
httpx==0.28.1
httpx-oauth==0.16.1
idna==3.10
iniconfig==2.3.0
kombu==5.5.4
makefun==1.16.0
Mako==1.3.10
MarkupSafe==3.0.2
multidict==6.6.4
packaging==25.0
pamqp==3.3.0
pluggy==1.6.0
prompt_toolkit==3.0.52
propcache==0.3.2
pwdlib==0.2.1
pycparser==2.23
pydantic==2.11.9
pydantic_core==2.33.2
Pygments==2.19.2
PyJWT==2.10.1
PyMySQL==1.1.2
pytest==8.4.2
pytest-asyncio==1.2.0
python-dateutil==2.9.0.post0
python-dotenv==1.1.1
python-multipart==0.0.20
PyYAML==6.0.2
six==1.17.0
sniffio==1.3.1
SQLAlchemy==2.0.43
starlette==0.48.0
tomli==2.2.1
typing-inspection==0.4.1
typing_extensions==4.15.0
tzdata==2025.2
uvicorn==0.37.0
uvloop==0.21.0
vine==5.1.0
watchfiles==1.1.0
wcwidth==0.2.14
websockets==15.0.1
yarl==1.20.1