Compare commits

..

1 Commits

Author SHA1 Message Date
Dejan Ribarovski
a76138614b Merge 8974561308 into d593f7a994 2025-10-17 15:14:17 +02:00
36 changed files with 197 additions and 854 deletions

View File

@@ -9,29 +9,6 @@ permissions:
pull-requests: write
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:
if: github.event.action != 'closed'
name: Build and push image (reusable)

View File

@@ -21,29 +21,6 @@ concurrency:
cancel-in-progress: false
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:
name: Build and push image (reusable)
uses: ./.github/workflows/build-image.yaml
@@ -108,9 +85,7 @@ jobs:
BANKID_CLIENT_SECRET: ${{ secrets.BANKID_CLIENT_SECRET }}
MOJEID_CLIENT_ID: ${{ secrets.MOJEID_CLIENT_ID }}
MOJEID_CLIENT_SECRET: ${{ secrets.MOJEID_CLIENT_SECRET }}
CSAS_CLIENT_ID: ${{ secrets.CSAS_CLIENT_ID }}
CSAS_CLIENT_SECRET: ${{ secrets.CSAS_CLIENT_SECRET }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
run: |
helm upgrade --install myapp ./7project/charts/myapp-chart \
-n prod --create-namespace \
@@ -126,7 +101,4 @@ jobs:
--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" \
--set-string oauth.mojeid.clientSecret="$MOJEID_CLIENT_SECRET"

View File

@@ -1,55 +0,0 @@
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

View File

@@ -1,32 +0,0 @@
"""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

@@ -1,40 +0,0 @@
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,30 +6,15 @@ from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from starlette.requests import Request
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.models.user import User
from app.services.user_service import current_active_verified_user
from app.api.auth import router as auth_router
from app.api.csas import router as csas_router
from app.api.categories import router as categories_router
from app.api.transactions import router as transactions_router
from app.services.user_service import auth_backend, current_active_verified_user, fastapi_users, get_oauth_provider, 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,
)
from app.services.user_service import auth_backend, current_active_verified_user, fastapi_users, get_oauth_provider
fastApi = FastAPI()
app = fastApi
# CORS for frontend dev server
fastApi.add_middleware(
@@ -60,6 +45,7 @@ async def log_traffic(request: Request, call_next):
"request_url": str(request.url),
"request_size": request.headers.get("content-length"),
"request_headers": dict(request.headers),
"request_body": await request.body(),
"response_status": response.status_code,
"response_size": response.headers.get("content-length"),
"response_headers": dict(response.headers),
@@ -75,7 +61,6 @@ fastApi.include_router(
auth_backend,
"SECRET",
associate_by_email=True,
redirect_url=os.getenv("FRONTEND_DOMAIN_SCHEME", "http://localhost:3000") + "/auth/mojeid/callback",
),
prefix="/auth/mojeid",
tags=["auth"],
@@ -87,13 +72,11 @@ fastApi.include_router(
auth_backend,
"SECRET",
associate_by_email=True,
redirect_url=os.getenv("FRONTEND_DOMAIN_SCHEME", "http://localhost:3000") + "/auth/bankid/callback",
),
prefix="/auth/bankid",
tags=["auth"],
)
fastApi.include_router(csas_router)
# Liveness/root endpoint
@fastApi.get("/", include_in_schema=False)
@@ -104,21 +87,3 @@ async def root():
@fastApi.get("/authenticated-route")
async def authenticated_route(user: User = Depends(current_active_verified_user)):
return {"message": f"Hello {user.email}!"}
@fastApi.get("/sentry-debug")
async def trigger_error():
division_by_zero = 1 / 0
@fastApi.get("/debug/scrape/csas/all", tags=["debug"])
async def debug_scrape_csas_all():
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,8 +1,6 @@
from sqlalchemy import Column, String
from sqlalchemy.orm import relationship, mapped_column, Mapped
from fastapi_users.db import SQLAlchemyBaseUserTableUUID, SQLAlchemyBaseOAuthAccountTableUUID
from sqlalchemy.sql.sqltypes import JSON
from app.core.base import Base
@@ -15,7 +13,6 @@ class User(SQLAlchemyBaseUserTableUUID, Base):
first_name = Column(String(length=100), nullable=True)
last_name = Column(String(length=100), nullable=True)
oauth_accounts = relationship("OAuthAccount", lazy="joined")
config = Column(JSON, default={})
# Relationship
transactions = relationship("Transaction", back_populates="user")

View File

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

View File

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

View File

@@ -1,28 +0,0 @@
-----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

@@ -1,31 +0,0 @@
-----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

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

View File

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

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

View File

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

View File

@@ -1,22 +0,0 @@
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

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

View File

@@ -1,18 +0,0 @@
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

@@ -1,55 +0,0 @@
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

@@ -78,16 +78,6 @@ spec:
secretKeyRef:
name: prod
key: BANKID_CLIENT_SECRET
- name: CSAS_CLIENT_ID
valueFrom:
secretKeyRef:
name: prod
key: CSAS_CLIENT_ID
- name: CSAS_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: prod
key: CSAS_CLIENT_SECRET
- name: DOMAIN
value: {{ required "Set .Values.domain" .Values.domain | quote }}
- name: DOMAIN_SCHEME
@@ -96,11 +86,6 @@ spec:
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:
httpGet:
path: /

View File

@@ -8,8 +8,6 @@ stringData:
MOJEID_CLIENT_SECRET: {{ .Values.oauth.mojeid.clientSecret | quote }}
BANKID_CLIENT_ID: {{ .Values.oauth.bankid.clientId | quote }}
BANKID_CLIENT_SECRET: {{ .Values.oauth.bankid.clientSecret | quote }}
CSAS_CLIENT_ID: {{ .Values.oauth.csas.clientId | quote }}
CSAS_CLIENT_SECRET: {{ .Values.oauth.csas.clientSecret | quote }}
# Database credentials
MARIADB_DB: {{ required "Set .Values.deployment" .Values.deployment | quote }}
MARIADB_USER: {{ required "Set .Values.deployment" .Values.deployment | quote }}
@@ -17,4 +15,3 @@ stringData:
# RabbitMQ credentials
RABBITMQ_PASSWORD: {{ .Values.rabbitmq.password | default "" | quote }}
RABBITMQ_USERNAME: {{ .Values.rabbitmq.username | quote }}
SENTRY_DSN: {{ .Values.sentry_dsn | quote }}

View File

@@ -65,18 +65,3 @@ spec:
value: {{ .Values.rabbitmq.vhost | default "/" | quote }}
- name: MAIL_QUEUE
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

@@ -16,8 +16,6 @@ domain_scheme: ""
frontend_domain: ""
frontend_domain_scheme: ""
sentry_dsn: ""
image:
repository: lukastrkan/cc-app-demo
# You can use a tag or digest. If digest is provided, it takes precedence.
@@ -46,9 +44,6 @@ oauth:
mojeid:
clientId: ""
clientSecret: ""
csas:
clientId: ""
clientSecret: ""
rabbitmq:
create: true

View File

@@ -0,0 +1,20 @@
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

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

View File

@@ -0,0 +1,20 @@
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

@@ -0,0 +1,15 @@
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

@@ -0,0 +1,48 @@
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

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

View File

@@ -0,0 +1,41 @@
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

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

View File

@@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from 'react';
import { type Category, type Transaction, createTransaction, getCategories, getTransactions } from '../api';
import AccountPage from './AccountPage';
import AppearancePage from './AppearancePage';
import { BACKEND_URL } from '../config';
function formatAmount(n: number) {
return new Intl.NumberFormat(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n);
@@ -15,27 +14,6 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
const [loading, setLoading] = useState(true);
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
const [amount, setAmount] = useState<string>('');
const [description, setDescription] = useState('');
@@ -119,12 +97,6 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
<main className="page space-y">
{current === 'home' && (
<>
<section className="card">
<h3>Bank connections</h3>
<p className="muted">Connect your CSAS (George) account.</p>
<button className="btn" onClick={startOauthCsas}>Connect CSAS (George)</button>
</section>
<section className="card">
<h3>Add Transaction</h3>
<form onSubmit={handleCreate} className="form-row">

View File

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

View File

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