feat: Mini-Apps registry, Stammdaten backend persistence, bank accounts
- Register 6 mini-apps in kommunikation plugin (contact_picker, file_share, calendar_invite, mail_forward, ai_search, task_create) - Connect AdressenTab to backend API (GET/POST/PATCH/DELETE /addresses) - Create BankAccount model, schema, service, routes + migration 0033 - Connect KontenTab to backend API (GET/POST/PATCH/DELETE /bank-accounts) - AI tools already working: 7 tools registered (hybrid_search, get_contact_mails, etc.)
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"""SQLAlchemy models for LeoCRM."""
|
||||
|
||||
from app.models.address import Address
|
||||
from app.models.bank_account import BankAccount
|
||||
from app.models.ai_conversation import AIConversation, AIMessage
|
||||
from app.models.attachment import Attachment
|
||||
from app.models.audit import AuditLog, DeletionLog
|
||||
@@ -48,6 +49,7 @@ __all__ = [
|
||||
"SystemSettings",
|
||||
"Attachment",
|
||||
"Address",
|
||||
"BankAccount",
|
||||
"Plugin",
|
||||
"PluginMigration",
|
||||
"AIConversation",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""BankAccount model — multiple bank accounts per tenant."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Boolean, String
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
|
||||
|
||||
class BankAccount(Base, TenantMixin):
|
||||
"""Bank account entity — multiple accounts per tenant.
|
||||
|
||||
is_default: one default bank account per tenant.
|
||||
"""
|
||||
|
||||
__tablename__ = "bank_accounts"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
bank_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
iban: Mapped[str] = mapped_column(String(34), nullable=False)
|
||||
bic: Mapped[str | None] = mapped_column(String(11), nullable=True)
|
||||
account_holder: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
default_tax: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
@@ -71,7 +71,57 @@ class KommunikationPlugin(BasePlugin):
|
||||
miniapp_registry = MiniAppRegistry()
|
||||
service_container.register("comm_miniapps", miniapp_registry)
|
||||
|
||||
logger.info("Kommunikation plugin activated — WebSocket + MiniApp registries ready")
|
||||
# Register built-in mini-apps
|
||||
miniapp_registry.register(
|
||||
app_id="contact_picker",
|
||||
name="Kontakt wählen",
|
||||
icon="👤",
|
||||
description="Kontakt aus dem CRM im Chat teilen",
|
||||
plugin_name="kommunikation",
|
||||
render_schema={"type": "object", "properties": {"contact_id": {"type": "string"}}},
|
||||
)
|
||||
miniapp_registry.register(
|
||||
app_id="file_share",
|
||||
name="Datei teilen",
|
||||
icon="📎",
|
||||
description="Datei aus dem DMS im Chat teilen",
|
||||
plugin_name="kommunikation",
|
||||
render_schema={"type": "object", "properties": {"file_id": {"type": "string"}}},
|
||||
)
|
||||
miniapp_registry.register(
|
||||
app_id="calendar_invite",
|
||||
name="Termin teilen",
|
||||
icon="📅",
|
||||
description="Kalender-Termin im Chat teilen",
|
||||
plugin_name="kommunikation",
|
||||
render_schema={"type": "object", "properties": {"event_id": {"type": "string"}}},
|
||||
)
|
||||
miniapp_registry.register(
|
||||
app_id="mail_forward",
|
||||
name="E-Mail weiterleiten",
|
||||
icon="✉️",
|
||||
description="E-Mail im Chat weiterleiten",
|
||||
plugin_name="kommunikation",
|
||||
render_schema={"type": "object", "properties": {"mail_id": {"type": "string"}}},
|
||||
)
|
||||
miniapp_registry.register(
|
||||
app_id="ai_search",
|
||||
name="KI Suche",
|
||||
icon="🔍",
|
||||
description="KI-gestützte Suche im CRM starten",
|
||||
plugin_name="kommunikation",
|
||||
render_schema={"type": "object", "properties": {"query": {"type": "string"}}},
|
||||
)
|
||||
miniapp_registry.register(
|
||||
app_id="task_create",
|
||||
name="Aufgabe erstellen",
|
||||
icon="✅",
|
||||
description="Aufgabe aus dem Chat erstellen",
|
||||
plugin_name="kommunikation",
|
||||
render_schema={"type": "object", "properties": {"title": {"type": "string"}, "due_date": {"type": "string"}}},
|
||||
)
|
||||
|
||||
logger.info("Kommunikation plugin activated — WebSocket + MiniApp registries ready (6 mini-apps registered)")
|
||||
|
||||
async def on_deactivate(self, db, service_container, event_bus) -> None:
|
||||
"""Clean up registries."""
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from app.routes import (
|
||||
addresses, # noqa: F401
|
||||
ai_copilot, # noqa: F401
|
||||
bank_accounts, # noqa: F401
|
||||
audit, # noqa: F401
|
||||
auth, # noqa: F401
|
||||
contacts, # noqa: F401
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Bank account routes — list, create, update, delete with tenant isolation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.deps import require_permission
|
||||
from app.schemas.bank_account import BankAccountCreate, BankAccountUpdate
|
||||
from app.services import bank_account_service
|
||||
|
||||
router = APIRouter(prefix="/api/v1/bank-accounts", tags=["bank-accounts"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_bank_accounts(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("bank-accounts:read")),
|
||||
):
|
||||
"""List all bank accounts for the current tenant."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
return await bank_account_service.list_bank_accounts(db, tenant_id)
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_bank_account(
|
||||
body: BankAccountCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("bank-accounts:write")),
|
||||
):
|
||||
"""Create a new bank account."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
|
||||
data = body.model_dump()
|
||||
try:
|
||||
return await bank_account_service.create_bank_account(db, tenant_id, user_id, data)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, detail={"detail": str(exc), "code": "invalid_value"}) from exc
|
||||
|
||||
|
||||
@router.patch("/{account_id}")
|
||||
async def update_bank_account(
|
||||
account_id: str,
|
||||
body: BankAccountUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("bank-accounts:write")),
|
||||
):
|
||||
"""Update a bank account."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
|
||||
try:
|
||||
aid = uuid.UUID(account_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid account_id", "code": "invalid_id"}) from None
|
||||
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
result = await bank_account_service.update_bank_account(db, tenant_id, user_id, aid, data)
|
||||
if result is None:
|
||||
raise HTTPException(404, detail={"detail": "Bank account not found", "code": "not_found"})
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/{account_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_bank_account(
|
||||
account_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_permission("bank-accounts:write")),
|
||||
):
|
||||
"""Soft-delete a bank account."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
|
||||
try:
|
||||
aid = uuid.UUID(account_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid account_id", "code": "invalid_id"}) from None
|
||||
|
||||
deleted = await bank_account_service.delete_bank_account(db, tenant_id, user_id, aid)
|
||||
if not deleted:
|
||||
raise HTTPException(404, detail={"detail": "Bank account not found", "code": "not_found"})
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Bank account schemas — create, update, read, list."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class BankAccountCreate(BaseModel):
|
||||
bank_name: str = Field(..., min_length=1, max_length=100)
|
||||
iban: str = Field(..., min_length=1, max_length=34)
|
||||
bic: str | None = Field(None, max_length=11)
|
||||
account_holder: str | None = Field(None, max_length=200)
|
||||
default_tax: str | None = Field(None, max_length=50)
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
class BankAccountUpdate(BaseModel):
|
||||
bank_name: str | None = Field(None, min_length=1, max_length=100)
|
||||
iban: str | None = Field(None, min_length=1, max_length=34)
|
||||
bic: str | None = Field(None, max_length=11)
|
||||
account_holder: str | None = Field(None, max_length=200)
|
||||
default_tax: str | None = Field(None, max_length=50)
|
||||
is_default: bool | None = None
|
||||
|
||||
|
||||
class BankAccountResponse(BaseModel):
|
||||
id: str
|
||||
bank_name: str
|
||||
iban: str
|
||||
bic: str | None = None
|
||||
account_holder: str | None = None
|
||||
default_tax: str | None = None
|
||||
is_default: bool
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class BankAccountListResponse(BaseModel):
|
||||
items: list[BankAccountResponse]
|
||||
total: int
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Bank account service — CRUD with tenant isolation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.audit import log_audit
|
||||
from app.models.bank_account import BankAccount
|
||||
|
||||
|
||||
def _account_to_dict(a: BankAccount) -> dict[str, Any]:
|
||||
"""Serialize a BankAccount ORM object to dict."""
|
||||
return {
|
||||
"id": str(a.id),
|
||||
"bank_name": a.bank_name,
|
||||
"iban": a.iban,
|
||||
"bic": a.bic,
|
||||
"account_holder": a.account_holder,
|
||||
"default_tax": a.default_tax,
|
||||
"is_default": a.is_default,
|
||||
"created_at": a.created_at.isoformat() if a.created_at else None,
|
||||
"updated_at": a.updated_at.isoformat() if a.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
async def list_bank_accounts(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> dict[str, Any]:
|
||||
"""List all bank accounts for a tenant."""
|
||||
q = (
|
||||
select(BankAccount)
|
||||
.where(
|
||||
BankAccount.tenant_id == tenant_id,
|
||||
BankAccount.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(BankAccount.is_default.desc(), BankAccount.bank_name.asc())
|
||||
)
|
||||
result = await db.execute(q)
|
||||
accounts = result.scalars().all()
|
||||
return {
|
||||
"items": [_account_to_dict(a) for a in accounts],
|
||||
"total": len(accounts),
|
||||
}
|
||||
|
||||
|
||||
async def create_bank_account(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
data: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new bank account. If is_default=True, unset other defaults first."""
|
||||
if data.get("is_default"):
|
||||
await db.execute(
|
||||
update(BankAccount)
|
||||
.where(
|
||||
BankAccount.tenant_id == tenant_id,
|
||||
BankAccount.is_default.is_(True),
|
||||
BankAccount.deleted_at.is_(None),
|
||||
)
|
||||
.values(is_default=False)
|
||||
)
|
||||
|
||||
account = BankAccount(
|
||||
tenant_id=tenant_id,
|
||||
bank_name=data["bank_name"],
|
||||
iban=data["iban"],
|
||||
bic=data.get("bic"),
|
||||
account_holder=data.get("account_holder"),
|
||||
default_tax=data.get("default_tax"),
|
||||
is_default=data.get("is_default", False),
|
||||
)
|
||||
db.add(account)
|
||||
await db.flush()
|
||||
await db.refresh(account)
|
||||
await log_audit(
|
||||
db, tenant_id, user_id, "create", "bank_account", account.id,
|
||||
changes={"bank_name": account.bank_name, "iban": account.iban},
|
||||
)
|
||||
return _account_to_dict(account)
|
||||
|
||||
|
||||
async def update_bank_account(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
account_id: uuid.UUID,
|
||||
data: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
"""Update a bank account. If setting is_default=True, unset other defaults first."""
|
||||
q = select(BankAccount).where(
|
||||
BankAccount.id == account_id,
|
||||
BankAccount.tenant_id == tenant_id,
|
||||
BankAccount.deleted_at.is_(None),
|
||||
)
|
||||
result = await db.execute(q)
|
||||
account = result.scalar_one_or_none()
|
||||
if account is None:
|
||||
return None
|
||||
|
||||
if data.get("is_default") is True and not account.is_default:
|
||||
await db.execute(
|
||||
update(BankAccount)
|
||||
.where(
|
||||
BankAccount.tenant_id == tenant_id,
|
||||
BankAccount.is_default.is_(True),
|
||||
BankAccount.id != account_id,
|
||||
BankAccount.deleted_at.is_(None),
|
||||
)
|
||||
.values(is_default=False)
|
||||
)
|
||||
|
||||
changes: dict[str, Any] = {}
|
||||
for field in ("bank_name", "iban", "bic", "account_holder", "default_tax", "is_default"):
|
||||
if field in data and data[field] is not None:
|
||||
old_val = getattr(account, field)
|
||||
changes[field] = {"old": old_val, "new": data[field]}
|
||||
setattr(account, field, data[field])
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(account)
|
||||
await log_audit(db, tenant_id, user_id, "update", "bank_account", account_id, changes=changes)
|
||||
return _account_to_dict(account)
|
||||
|
||||
|
||||
async def delete_bank_account(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
account_id: uuid.UUID,
|
||||
) -> bool:
|
||||
"""Soft-delete a bank account."""
|
||||
q = select(BankAccount).where(
|
||||
BankAccount.id == account_id,
|
||||
BankAccount.tenant_id == tenant_id,
|
||||
BankAccount.deleted_at.is_(None),
|
||||
)
|
||||
result = await db.execute(q)
|
||||
account = result.scalar_one_or_none()
|
||||
if account is None:
|
||||
return False
|
||||
|
||||
account.deleted_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
await log_audit(
|
||||
db, tenant_id, user_id, "delete", "bank_account", account_id,
|
||||
changes={"bank_name": account.bank_name},
|
||||
)
|
||||
return True
|
||||
Reference in New Issue
Block a user