Compare commits

..

1 Commits

Author SHA1 Message Date
13ed466031 Merge 32764ab1b0 into 4ea6876b74 2025-10-11 18:34:44 +00:00
21 changed files with 44 additions and 646 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,11 @@
from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
import app.services.user_service
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.categories import router as categories_router
from app.api.transactions import router as transactions_router
from app.services.user_service import auth_backend, current_active_verified_user, fastapi_users, get_oauth_provider
from app.schemas.user import UserCreate, UserRead, UserUpdate
from app.services.user_service import auth_backend, current_active_verified_user, fastapi_users
fastApi = FastAPI()
@@ -23,13 +21,33 @@ fastApi.add_middleware(
allow_headers=["*"],
)
fastApi.include_router(auth_router)
fastApi.include_router(categories_router)
fastApi.include_router(transactions_router)
fastApi.include_router(
fastapi_users.get_auth_router(auth_backend), prefix="/auth/jwt", tags=["auth"]
)
fastApi.include_router(
fastapi_users.get_register_router(UserRead, UserCreate),
prefix="/auth",
tags=["auth"],
)
fastApi.include_router(
fastapi_users.get_reset_password_router(),
prefix="/auth",
tags=["auth"],
)
fastApi.include_router(
fastapi_users.get_verify_router(UserRead),
prefix="/auth",
tags=["auth"],
)
fastApi.include_router(
fastapi_users.get_users_router(UserRead, UserUpdate),
prefix="/users",
tags=["users"],
)
fastApi.include_router(
fastapi_users.get_oauth_router(
get_oauth_provider("MojeID"),
app.services.user_service.get_oauth_provider("MojeID"),
auth_backend,
"SECRET",
associate_by_email=True,
@@ -38,17 +56,6 @@ fastApi.include_router(
tags=["auth"],
)
fastApi.include_router(
fastapi_users.get_oauth_router(
get_oauth_provider("BankID"),
auth_backend,
"SECRET",
associate_by_email=True,
),
prefix="/auth/bankid",
tags=["auth"],
)
# Liveness/root endpoint
@fastApi.get("/", include_in_schema=False)

View File

@@ -1,12 +1,13 @@
from typing import List
from sqlalchemy import Column, String
from sqlalchemy.orm import relationship, mapped_column, Mapped
from sqlalchemy.orm import relationship
from fastapi_users.db import SQLAlchemyBaseUserTableUUID, SQLAlchemyBaseOAuthAccountTableUUID
from app.core.base import Base
class OAuthAccount(SQLAlchemyBaseOAuthAccountTableUUID, Base):
# BankID token is longer than default
access_token: Mapped[str] = mapped_column(String(length=4096), nullable=False)
pass
class User(SQLAlchemyBaseUserTableUUID, Base):

View File

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

View File

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

View File

@@ -1,12 +1,11 @@
import json
from typing import Optional, Literal, Any
from httpx_oauth.clients.openid import OpenID
from httpx_oauth.oauth2 import T
from app.oauth.custom_openid import CustomOpenID
class MojeIDOAuth(CustomOpenID):
class MojeIDOAuth(OpenID):
def __init__(self, client_id: str, client_secret: str):
super().__init__(
client_id,

View File

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

View File

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

View File

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

View File

@@ -10,17 +10,13 @@ from fastapi_users.authentication import (
)
from fastapi_users.authentication.strategy.jwt import JWTStrategy
from fastapi_users.db import SQLAlchemyUserDatabase
from httpx_oauth.oauth2 import BaseOAuth2
from app.models.user import User
from app.oauth.bank_id import BankID
from app.oauth.custom_openid import CustomOpenID
from app.oauth.moje_id import MojeIDOAuth
from app.services.db import get_user_db
from app.core.queue import enqueue_email
SECRET = os.getenv("SECRET", "CHANGE_ME_SECRET")
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:5173")
BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8000")
@@ -28,15 +24,11 @@ providers = {
"MojeID": MojeIDOAuth(
os.getenv("MOJEID_CLIENT_ID", "CHANGE_ME_CLIENT_ID"),
os.getenv("MOJEID_CLIENT_SECRET", "CHANGE_ME_CLIENT_SECRET"),
),
"BankID": BankID(
os.getenv("BANKID_CLIENT_ID", "CHANGE_ME_CLIENT_ID"),
os.getenv("BANKID_CLIENT_SECRET", "CHANGE_ME_CLIENT_SECRET"),
)
}
def get_oauth_provider(name: str) -> Optional[BaseOAuth2]:
def get_oauth_provider(name: str) -> Optional[MojeIDOAuth]:
if name not in providers:
return None
return providers[name]
@@ -57,7 +49,7 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
# set additional user info from the OAuth provider
provider = get_oauth_provider(oauth_name)
if provider is not None and isinstance(provider, CustomOpenID):
if provider is not None and hasattr(provider, "get_user_info"):
update_dict = await provider.get_user_info(access_token)
await self.user_db.update(user, update_dict)

View File

@@ -96,13 +96,6 @@ module "database" {
phpmyadmin_enabled = var.phpmyadmin_enabled
cloudflare_domain = var.cloudflare_domain
s3_enabled = var.s3_enabled
s3_bucket = var.s3_bucket
s3_region = var.s3_region
s3_endpoint = var.s3_endpoint
s3_key_id = var.s3_key_id
s3_key_secret = var.s3_key_secret
}
#module "argocd" {

View File

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

View File

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

View File

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

View File

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

View File

@@ -59,7 +59,7 @@ resource "helm_release" "mariadb-operator" {
resource "helm_release" "maxscale_helm" {
name = "maxscale-helm"
chart = "${path.module}/charts/maxscale-helm"
version = "1.0.14"
version = "1.0.8"
depends_on = [helm_release.mariadb-operator-crds, kubectl_manifest.secrets]
timeout = 3600
@@ -71,12 +71,6 @@ resource "helm_release" "maxscale_helm" {
{ name = "metallb.primary_ip", value = var.primary_ip },
{ name = "metallb.secondary_ip", value = var.secondary_ip },
{ name = "phpmyadmin.enabled", value = tostring(var.phpmyadmin_enabled) },
{ name = "base_domain", value = var.cloudflare_domain },
{ name = "s3.key_id", value = var.s3_key_id },
{ name = "s3.key_secret", value = var.s3_key_secret },
{ name = "s3.enabled", value = var.s3_enabled },
{ name = "s3.endpoint", value = var.s3_endpoint },
{ name = "s3.region", value = var.s3_region },
{ name = "s3.bucket", value = var.s3_bucket },
{ name = "base_domain", value = var.cloudflare_domain }
]
}

View File

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

View File

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