hotfix msft/google login tokens end to end separated from connection
feat(billing): Nutzerhinweise bei leerem Budget + Mandats-Mail (402/SSE) Gateway - InsufficientBalanceException: billingModel, userAction (TOP_UP_SELF / CONTACT_MANDATE_ADMIN), DE/EN-Texte, toClientDict(), fromBalanceCheck() - HTTP 402 + JSON detail für globale API-Fehlerbehandlung - AI/Chatbot: vor Raise ggf. E-Mail an BillingSettings.notifyEmails (PREPAY_MANDATE, Throttle 1h/Mandat) via billingExhaustedNotify - Agent-Loop & Workspace-Route: SSE-ERROR mit strukturiertem Billing-Payload - datamodelBilling: notifyEmails-Doku für Pool-Alerts frontend_nyla - useWorkspace: SSE onError für INSUFFICIENT_BALANCE mit messageDe/En und Hinweis auf Billing-Pfad bei TOP_UP_SELF
This commit is contained in:
parent
eefb37050e
commit
0a0973d41b
32 changed files with 2179 additions and 1539 deletions
16
app.py
16
app.py
|
|
@ -8,7 +8,8 @@ from urllib.parse import quote_plus
|
||||||
|
|
||||||
os.environ["NUMEXPR_MAX_THREADS"] = "12"
|
os.environ["NUMEXPR_MAX_THREADS"] = "12"
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.security import HTTPBearer
|
from fastapi.security import HTTPBearer
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
@ -493,6 +494,19 @@ from slowapi import _rate_limit_exceeded_handler
|
||||||
app.state.limiter = limiter
|
app.state.limiter = limiter
|
||||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||||
|
|
||||||
|
|
||||||
|
async def _insufficientBalanceHandler(request: Request, exc: Exception):
|
||||||
|
"""HTTP 402 with structured billing hint (PREPAY_USER vs PREPAY_MANDATE)."""
|
||||||
|
payload = exc.toClientDict() if hasattr(exc, "toClientDict") else {"error": "INSUFFICIENT_BALANCE", "message": str(exc)}
|
||||||
|
return JSONResponse(status_code=402, content={"detail": payload})
|
||||||
|
|
||||||
|
|
||||||
|
from modules.serviceCenter.services.serviceBilling.mainServiceBilling import (
|
||||||
|
InsufficientBalanceException,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_exception_handler(InsufficientBalanceException, _insufficientBalanceHandler)
|
||||||
|
|
||||||
# CSRF protection middleware
|
# CSRF protection middleware
|
||||||
from modules.auth import CSRFMiddleware
|
from modules.auth import CSRFMiddleware
|
||||||
from modules.auth import (
|
from modules.auth import (
|
||||||
|
|
|
||||||
24
env_dev.env
24
env_dev.env
|
|
@ -31,9 +31,20 @@ APP_LOGGING_FILE_ENABLED = True
|
||||||
APP_LOGGING_ROTATION_SIZE = 10485760
|
APP_LOGGING_ROTATION_SIZE = 10485760
|
||||||
APP_LOGGING_BACKUP_COUNT = 5
|
APP_LOGGING_BACKUP_COUNT = 5
|
||||||
|
|
||||||
# Service Redirects
|
# OAuth: Auth app (login/JWT) vs Data app (Microsoft Graph / Google APIs). Same IDs until you split apps in Azure / GCP.
|
||||||
Service_MSFT_REDIRECT_URI = http://localhost:8000/api/msft/auth/callback
|
Service_MSFT_AUTH_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
|
||||||
Service_GOOGLE_REDIRECT_URI = http://localhost:8000/api/google/auth/callback
|
Service_MSFT_AUTH_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm83T29rV1pQelMtc1p1MXR4NTFpa19CTEhHQ0xfNmdPUmZqcWp5UHBMS0hYTGl4c1pPdmhTNTJVWUl5WnlnUUZhV0VTRzVCb0d5YjR1NnZPZk5CZ0dGazNGdUJVbjkxeVdrYlNiVjJUYzF2aVFtQnVxTHFqTTJqZlF0RTFGNmE1OGN1TEk=
|
||||||
|
Service_MSFT_AUTH_REDIRECT_URI = http://localhost:8000/api/msft/auth/login/callback
|
||||||
|
Service_MSFT_DATA_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
|
||||||
|
Service_MSFT_DATA_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm83T29rV1pQelMtc1p1MXR4NTFpa19CTEhHQ0xfNmdPUmZqcWp5UHBMS0hYTGl4c1pPdmhTNTJVWUl5WnlnUUZhV0VTRzVCb0d5YjR1NnZPZk5CZ0dGazNGdUJVbjkxeVdrYlNiVjJUYzF2aVFtQnVxTHFqTTJqZlF0RTFGNmE1OGN1TEk=
|
||||||
|
Service_MSFT_DATA_REDIRECT_URI = http://localhost:8000/api/msft/auth/connect/callback
|
||||||
|
|
||||||
|
Service_GOOGLE_AUTH_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
|
||||||
|
Service_GOOGLE_AUTH_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETDJhbGVQMHlFQzNPVFI1ZzBMa3pNMGlQUHhaQm10eVl1bFlSeTBybzlTOWE2MURXQ0hkRlo0NlNGbHQxWEl1OVkxQnVKYlhhOXR1cUF4T3k0WDdscktkY1oyYllRTmdDTWpfbUdwWGtSd1JvNlYxeTBJdEtaaS1vYnItcW0yaFM=
|
||||||
|
Service_GOOGLE_AUTH_REDIRECT_URI = http://localhost:8000/api/google/auth/login/callback
|
||||||
|
Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
|
||||||
|
Service_GOOGLE_DATA_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETDJhbGVQMHlFQzNPVFI1ZzBMa3pNMGlQUHhaQm10eVl1bFlSeTBybzlTOWE2MURXQ0hkRlo0NlNGbHQxWEl1OVkxQnVKYlhhOXR1cUF4T3k0WDdscktkY1oyYllRTmdDTWpfbUdwWGtSd1JvNlYxeTBJdEtaaS1vYnItcW0yaFM=
|
||||||
|
Service_GOOGLE_DATA_REDIRECT_URI = http://localhost:8000/api/google/auth/connect/callback
|
||||||
|
|
||||||
# Stripe Billing (both end with _SECRET for encryption script)
|
# Stripe Billing (both end with _SECRET for encryption script)
|
||||||
STRIPE_SECRET_KEY_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGWDkxSldfM0NCZ3dmbHY5cS1nQlI3UWZ4ZWRrNVdUdEFKa25RckRiQWY0c1E5MjVsZzlfRkZEU0VFU2tNQ01qZnRNQ0pZVU9hVFN6OEU0RXhwdTl3algzLWJlSXRhYmZlMHltSC1XejlGWEU5TDF1LUlYNEh1aG9tRFI4YmlCYzUyei02U1dabWoyb0N2dVFSb1RhWTNnQjBCZkFjV0FfOWdYdDVpX1k5R2pYM1R6SHRiaE10V1l1dnQybjVHWDRiQUJLM0UxRDZnczhJZGFsc3JhOU82QT09
|
STRIPE_SECRET_KEY_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGWDkxSldfM0NCZ3dmbHY5cS1nQlI3UWZ4ZWRrNVdUdEFKa25RckRiQWY0c1E5MjVsZzlfRkZEU0VFU2tNQ01qZnRNQ0pZVU9hVFN6OEU0RXhwdTl3algzLWJlSXRhYmZlMHltSC1XejlGWEU5TDF1LUlYNEh1aG9tRFI4YmlCYzUyei02U1dabWoyb0N2dVFSb1RhWTNnQjBCZkFjV0FfOWdYdDVpX1k5R2pYM1R6SHRiaE10V1l1dnQybjVHWDRiQUJLM0UxRDZnczhJZGFsc3JhOU82QT09
|
||||||
|
|
@ -48,15 +59,8 @@ Connector_AiTavily_API_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEQTdnUHMwd2pIaXNtMmtCTFRE
|
||||||
Connector_AiPrivateLlm_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGRHM5eFdUVmVZU1R1cHBwN1RlMUx4T0NlLTJLUFFVX3J2OElDWFpuZmJHVmp4Z3BNNWMwZUVVZUd2TFhRSjVmVkVlcFlVRWtybXh0ZHloZ01ZcnVvX195YjdlWVdEcjZSWFFTTlNBWUlaTlNoLWhqVFBIb0thVlBiaWhjYjFQOFY=
|
Connector_AiPrivateLlm_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGRHM5eFdUVmVZU1R1cHBwN1RlMUx4T0NlLTJLUFFVX3J2OElDWFpuZmJHVmp4Z3BNNWMwZUVVZUd2TFhRSjVmVkVlcFlVRWtybXh0ZHloZ01ZcnVvX195YjdlWVdEcjZSWFFTTlNBWUlaTlNoLWhqVFBIb0thVlBiaWhjYjFQOFY=
|
||||||
Connector_AiMistral_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGeEQxYUIxOHhia0JlQWpWQ2dWQWZzY3l6SWwyUnJoR1hRQWloX2lxb2lGNkc4UnA4U2tWNjJaYzB1d1hvNG9fWUp1N3V4OW9FMGhaWVhjSlVwWEc1X2loVDBSZDEtdHdfcTA5QkcxQTR4OHc4RkRzclJrU2d1RFZpNDJkRDRURlE=
|
Connector_AiMistral_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGeEQxYUIxOHhia0JlQWpWQ2dWQWZzY3l6SWwyUnJoR1hRQWloX2lxb2lGNkc4UnA4U2tWNjJaYzB1d1hvNG9fWUp1N3V4OW9FMGhaWVhjSlVwWEc1X2loVDBSZDEtdHdfcTA5QkcxQTR4OHc4RkRzclJrU2d1RFZpNDJkRDRURlE=
|
||||||
|
|
||||||
# Microsoft Service Configuration
|
|
||||||
Service_MSFT_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
|
|
||||||
Service_MSFT_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm83T29rV1pQelMtc1p1MXR4NTFpa19CTEhHQ0xfNmdPUmZqcWp5UHBMS0hYTGl4c1pPdmhTNTJVWUl5WnlnUUZhV0VTRzVCb0d5YjR1NnZPZk5CZ0dGazNGdUJVbjkxeVdrYlNiVjJUYzF2aVFtQnVxTHFqTTJqZlF0RTFGNmE1OGN1TEk=
|
|
||||||
Service_MSFT_TENANT_ID = common
|
Service_MSFT_TENANT_ID = common
|
||||||
|
|
||||||
# Google Service configuration
|
|
||||||
Service_GOOGLE_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
|
|
||||||
Service_GOOGLE_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETDJhbGVQMHlFQzNPVFI1ZzBMa3pNMGlQUHhaQm10eVl1bFlSeTBybzlTOWE2MURXQ0hkRlo0NlNGbHQxWEl1OVkxQnVKYlhhOXR1cUF4T3k0WDdscktkY1oyYllRTmdDTWpfbUdwWGtSd1JvNlYxeTBJdEtaaS1vYnItcW0yaFM=
|
|
||||||
|
|
||||||
# Google Cloud Speech Services configuration
|
# Google Cloud Speech Services configuration
|
||||||
Connector_GoogleSpeech_API_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETk5FWWM3Q0JKMzhIYTlyMkhuNjA4NlF4dk82U2NScHhTVGY3UG83NkhfX3RrcWVtWWcyLXRjU1dTT21zWEl6YWRMMUFndXpsUnJOeHh3QThsNDZKRXROTzdXRUdsT0JZajZJNVlfb0gtMXkwWm9DOERPVnpjU0pyUEZfOGJsUnprT3ltMVVhalUyUm9hMUFtZEtHUnJqOGZ4dEZjZm5SWVVTckVCWnY1UkdVSHVmUlgwbnAyc0xDQW84R3ViSko5OHVCVWZRUVNiaG1pVFB6X3EwS0FPd2dUYjhiSmRjcXh2WEZiXzI4SFZqT21tbDduUWRyVWdFZXpmcVM5ZDR0VWtzZnF5UER6cGwwS2JlLV9CSTZ0Z0IyQ1h0YW9TcmhRTXZEckp4bWhmTkt6UTNYMk4zVkpnbUJmaDIxZnoyR2dWTEYwTUFEV0w2eUdUUGpoZk9XRkt4RVF1Z1NPdUpBeTcyWV9PY1Ffd2s0ZEdVekxGekhoeEl4TmNqaXYtbUJuSVdycFducERWdWtZajZnX011Q2w4eE9VMTBqQ1ZxRmdScWhXY1E3WWhzX1JZcHhxam9FbDVPN3Q1MWtrMUZuTUg3LVFQVHp1T1hpQWNDMzEzekVJWk9ybl91YUVjSkFob1VaMi1ONEtuMnRSOEg1S3QybUMwbVZDejItajBLTjM2Zy1hNzZQMW5LLVVDVGdFWm5BZUxNeEFnUkZzU3dxV0lCUlc0LWo4b05GczVpOGZSV2ZxbFBwUml6OU5tYjdnTks3Y3hrVEZVTHlmc1NPdFh4WE5pWldEZklOQUxBbjBpMTlkX3FFQVJ6c2NSZGdzTThycE92VW82enZKamhiRGFnU25aZGlHZHhZd2lUUmhuTVptNjhoWVlJQkxIOEkzbzJNMjZCZFJyM25tdXBnQ2ZWaHV3b2p6UWJpdk9xUEhBc1dyTlNmeF9wbm5yYUhHV01UZnVXWDFlNzBkdXlWUWhvcmJpSmljbmE3LUpUZEg4VzRwZ2JVSjdYUm1sODViQXVxUzdGTmZFbVpiN2V1YW5XV3U4b2VRWmxldGVGVHZsSldoekhVLU9wZ2V0cGZIYkNqM2pXVGctQVAyUm4xTHhpd1VVLXFhcnVEV21Rby1hbTlqTl84TjVveHdYTExUVkhHQ0ltaTB2WXJnY1NQVE5PbWg3ejgySElYc1JSTlQ3NDlFUWR6STZVUjVqaXFRN200NF9LY1ljQ0R2UldlWUtKY1NQVnJ4QXRyYTBGSWVuenhyM0Z0cWtndTd1eG8xRzY5a2dNZ1hkQm5MV3BHVzA2N1QwUkd6WlRGYTZQOUhnVWQ2S0Y5U0s1dXFNVXh5Q2pLWVUxSUQ2MlR1ak52NmRIZ2hlYTk1SGZGWS1RV3hWVU9rR3d1Rk9MLS11REZXbzhqMHpsSm1HYW1jMUNLT29YOHZsRWNaLTVvOFpmT3l3MHVwaERTT0dNLWFjcGRYZ25qT2szTkVFUnRFR3JWYS1aNXFIRnMyalozTlQzNFF2NXJLVHVPVF9zdTF6ZjlkbzJ4RFc2ZENmNFFxZDZzTzhfMUl0bW96V0lPZkh1dXFYZlEteFBlSG84Si1FNS1TTi1OMkFnX2pOYW8xY3MxMVJnVC02MDUyaXZfMEVHWDQtVlRpcENmV0h3V0dCWEFRS2prQXdNRlQ5dnRFVHU0Q1dNTmh0SlBCaU55bFMydWM1TTFFLW96ODBnV3dNZHFZTWZhRURYSHlrdzF3RlRuWDBoQUhSOUJWemtRM3pxcDJFbGJoaTJ3ZktRTlJxbXltaHBoZXVJVDlxS3cxNWo2c0ZBV0NzaUstRWdsMW1xLXFkanZGYUFiU0tSLXFQa0tkcDFoMV9kak41ZjQ0R214UmtOR1ZBanRuemY3Mmw1SkZ5aDZodGIzT3N2aV85MW9kcld6c0g0ZDgtTWo3b3Y3VjJCRnR2U2tMVm9rUXNVRnVHbzZXVTZ6RmI2RkNmajBfMWVnODVFbnpkT0oyci15czJHU0p1cUowTGZJMzVnd3hIRjQyTVhKOGRkcFRKdVpyQ3Yzd01Jb1lSajFmV0paeEV0cjk1SmpmdWpDVFJMUmMtUFctOGhaTmlKQXNRVlVUNlhJemxudHZCR056SVlBb3NOTEYxRTRLaFlVd2d3TWtxVlB6ZEtQLTkxOGMyY3N0a2pYRFUweDBNaGhja2xSSklPOUZla1dKTWRNbG8tUGdSNEV5cW90OWlOZFlIUExBd3U2b2hyS1owbXVMM3p0Qm41cUtzWUxYNzB1N3JpUTNBSGdsT0NuamNTb1lIbXR4MG1sakNPVkxBUXRLVE1xX0YxWDhOcERIY1lTQVFqS01CaXZKNllFaXlIR0JsM1pKMmV1OUo3TGI1WkRaVnYxUTl1LTM0SU1qN1V1b0RCT0x0VHNLTmNLZnk1S0MxYnBBcm03WnVua0xqaEhGUzhOU253ZkppRzdudXBSVlMxeFVOSWxtZ1o2RVBSQUhEUEFuQ1hxSVZMME4yWUtaU3VyRGo3RkUyRUNjT0pNcE1BdE1ZRzdXVl8ydUtXZjdMdHdEVW4teHUtTi1HSGliLUxud21TX0NtcGVkRFBHNkZ1WTlNczR4OUJfUVluc1BoV09oWS1scUdsNnB5d1U5M1huX3k4QzAyNldtb2hybktYN2xKZ1NTNWFsaWwzV3pCRVhkaGR5eTNlV1d6ZzFfaFZTT0E4UjRpQ3pKdEZxUlJ6UFZXM3laUndyWEk2NlBXLUpoajVhZzVwQXpWVzUtVjVNZFBwdWdQa3AxZC1KdGdqNnhibjN4dmFYb2cxcEVwc1g5R09zRUdINUZtOE5QRjVUU0dpZy1QVl9odnFtVDNuWFZLSURtMXlSMlhRNTBWSVFJbEdOOWpfVWV0SmdRWDdlUXZZWE8xRUxDN1I0aEN6MHYwNzM1cmpJS0ZpMnBYWkxfb3FsbEV1VnlqWGxqdVJ6SHlwSjAzRlMycTBaQ295NXNnZERpUnJQcjhrUUd3bkI4bDVzRmxQblhkaFJPTTdISnVUQmhET3BOMTM4bjVvUEc2VmZhb2lrR1FyTUl2RWNEeGg0U0dsNnV6eU5zOUxiNDY5SXBxR0hBS00wOTgyWTFnWkQyaEtLVUloT3ZxZGh0RWVGRmJzenFsaUtfZENQM0JzdkVVeTdXR3hUSmJST1NBMUI1NkVFWncwNW5JZVVLX1p1RXdqVnFfQWpvQ08yQjZhN1NkTkpTSnUxOVRXZXE0WFEtZWxhZW1NNXYtQ2sya0VGLURmS01lMkctNVY3c2ZhN0ZGRFgwWHlabTFkeS1hcUZ1dDZ3cnpPQ3hha2IzVE11M0pqbklmU0diczBqTFBNZC1QZGp6VzNTSnJVSjJoWkJUQjVORG4tYUJmMEJtSUNUdVpEaGt6OTM3TjFOdVhXUHItZjRtZ25nU3NhZC1sVTVXNTRDTmxZbnlfeHNsdkpuMXhUYnE1MnpVQ0ZOclRWM1M4eHdXTzRXbFRZZVQtTS1iRVdXVWZMSGotcWg3MUxUYTFnSEEtanBCRHlZRUNIdGdpUFhsYjdYUndCZnRITzhMZVJ1dHFoVlVNb0duVjlxd0U4OGRuQVV3MG90R0hiYW5MWkxWVklzbWFRNzBfSUNrdzc5bVdtTXg0dExEYnRCaDI3c1I4TWFwLXZKR0wxSjRZYjZIV3ZqZjNqTWhFT0RGSDVMc1A1UzY2bDBiMGFSUy1fNVRQRzRJWDVydUpqb1ZfSHNVbldVeUN2YlAxSW5WVDdxVzJ1WHpLeUdmb0xWMDNHN05oQzY3YnhvUUdhS2xaOHNidkVvbTZtSHFlblhOYmwyR3NQdVJDRUdxREhWdF9ZcXhwUWxHc2hyLW5vUGhIUVhJNUNhY0hFU0ptVnI0TFVhZDE1TFBBUEstSkRoZWJ5MHJhUmZrR1ZrRlFtRGpxS1pOMmFMQjBsdjluY3FiYUU4eGJVVXlZVEpuNWdHVVhJMGtwaTdZR2NDbXd2eHpOQ09SeTV6N1BaVUpsR1pQVDBZcElJUUt6VnVpQmxSYnE4Y1BCWV9IRWdVV0p3enBGVHItdnBGN3NyNWFBWmkySnByWThsbDliSlExQmp3LVlBaDIyZXp6UnR6cU9rTzJmTDBlSVpON0tiWllMdm1oME1zTFl2S2ZYYllhQlY2VHNZRGtHUDY4U1lIVExLZTU4VzZxSTZrZHl1ZTBDc0g4SjI4WGYyZHV1bm9wQ3R2Z09ld1ZmUkN5alJGeHZKSHl1bWhQVXpNMzdjblpLcUhfSm02Qlh5S1FVN3lIcHl0NnlRPT0=
|
Connector_GoogleSpeech_API_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETk5FWWM3Q0JKMzhIYTlyMkhuNjA4NlF4dk82U2NScHhTVGY3UG83NkhfX3RrcWVtWWcyLXRjU1dTT21zWEl6YWRMMUFndXpsUnJOeHh3QThsNDZKRXROTzdXRUdsT0JZajZJNVlfb0gtMXkwWm9DOERPVnpjU0pyUEZfOGJsUnprT3ltMVVhalUyUm9hMUFtZEtHUnJqOGZ4dEZjZm5SWVVTckVCWnY1UkdVSHVmUlgwbnAyc0xDQW84R3ViSko5OHVCVWZRUVNiaG1pVFB6X3EwS0FPd2dUYjhiSmRjcXh2WEZiXzI4SFZqT21tbDduUWRyVWdFZXpmcVM5ZDR0VWtzZnF5UER6cGwwS2JlLV9CSTZ0Z0IyQ1h0YW9TcmhRTXZEckp4bWhmTkt6UTNYMk4zVkpnbUJmaDIxZnoyR2dWTEYwTUFEV0w2eUdUUGpoZk9XRkt4RVF1Z1NPdUpBeTcyWV9PY1Ffd2s0ZEdVekxGekhoeEl4TmNqaXYtbUJuSVdycFducERWdWtZajZnX011Q2w4eE9VMTBqQ1ZxRmdScWhXY1E3WWhzX1JZcHhxam9FbDVPN3Q1MWtrMUZuTUg3LVFQVHp1T1hpQWNDMzEzekVJWk9ybl91YUVjSkFob1VaMi1ONEtuMnRSOEg1S3QybUMwbVZDejItajBLTjM2Zy1hNzZQMW5LLVVDVGdFWm5BZUxNeEFnUkZzU3dxV0lCUlc0LWo4b05GczVpOGZSV2ZxbFBwUml6OU5tYjdnTks3Y3hrVEZVTHlmc1NPdFh4WE5pWldEZklOQUxBbjBpMTlkX3FFQVJ6c2NSZGdzTThycE92VW82enZKamhiRGFnU25aZGlHZHhZd2lUUmhuTVptNjhoWVlJQkxIOEkzbzJNMjZCZFJyM25tdXBnQ2ZWaHV3b2p6UWJpdk9xUEhBc1dyTlNmeF9wbm5yYUhHV01UZnVXWDFlNzBkdXlWUWhvcmJpSmljbmE3LUpUZEg4VzRwZ2JVSjdYUm1sODViQXVxUzdGTmZFbVpiN2V1YW5XV3U4b2VRWmxldGVGVHZsSldoekhVLU9wZ2V0cGZIYkNqM2pXVGctQVAyUm4xTHhpd1VVLXFhcnVEV21Rby1hbTlqTl84TjVveHdYTExUVkhHQ0ltaTB2WXJnY1NQVE5PbWg3ejgySElYc1JSTlQ3NDlFUWR6STZVUjVqaXFRN200NF9LY1ljQ0R2UldlWUtKY1NQVnJ4QXRyYTBGSWVuenhyM0Z0cWtndTd1eG8xRzY5a2dNZ1hkQm5MV3BHVzA2N1QwUkd6WlRGYTZQOUhnVWQ2S0Y5U0s1dXFNVXh5Q2pLWVUxSUQ2MlR1ak52NmRIZ2hlYTk1SGZGWS1RV3hWVU9rR3d1Rk9MLS11REZXbzhqMHpsSm1HYW1jMUNLT29YOHZsRWNaLTVvOFpmT3l3MHVwaERTT0dNLWFjcGRYZ25qT2szTkVFUnRFR3JWYS1aNXFIRnMyalozTlQzNFF2NXJLVHVPVF9zdTF6ZjlkbzJ4RFc2ZENmNFFxZDZzTzhfMUl0bW96V0lPZkh1dXFYZlEteFBlSG84Si1FNS1TTi1OMkFnX2pOYW8xY3MxMVJnVC02MDUyaXZfMEVHWDQtVlRpcENmV0h3V0dCWEFRS2prQXdNRlQ5dnRFVHU0Q1dNTmh0SlBCaU55bFMydWM1TTFFLW96ODBnV3dNZHFZTWZhRURYSHlrdzF3RlRuWDBoQUhSOUJWemtRM3pxcDJFbGJoaTJ3ZktRTlJxbXltaHBoZXVJVDlxS3cxNWo2c0ZBV0NzaUstRWdsMW1xLXFkanZGYUFiU0tSLXFQa0tkcDFoMV9kak41ZjQ0R214UmtOR1ZBanRuemY3Mmw1SkZ5aDZodGIzT3N2aV85MW9kcld6c0g0ZDgtTWo3b3Y3VjJCRnR2U2tMVm9rUXNVRnVHbzZXVTZ6RmI2RkNmajBfMWVnODVFbnpkT0oyci15czJHU0p1cUowTGZJMzVnd3hIRjQyTVhKOGRkcFRKdVpyQ3Yzd01Jb1lSajFmV0paeEV0cjk1SmpmdWpDVFJMUmMtUFctOGhaTmlKQXNRVlVUNlhJemxudHZCR056SVlBb3NOTEYxRTRLaFlVd2d3TWtxVlB6ZEtQLTkxOGMyY3N0a2pYRFUweDBNaGhja2xSSklPOUZla1dKTWRNbG8tUGdSNEV5cW90OWlOZFlIUExBd3U2b2hyS1owbXVMM3p0Qm41cUtzWUxYNzB1N3JpUTNBSGdsT0NuamNTb1lIbXR4MG1sakNPVkxBUXRLVE1xX0YxWDhOcERIY1lTQVFqS01CaXZKNllFaXlIR0JsM1pKMmV1OUo3TGI1WkRaVnYxUTl1LTM0SU1qN1V1b0RCT0x0VHNLTmNLZnk1S0MxYnBBcm03WnVua0xqaEhGUzhOU253ZkppRzdudXBSVlMxeFVOSWxtZ1o2RVBSQUhEUEFuQ1hxSVZMME4yWUtaU3VyRGo3RkUyRUNjT0pNcE1BdE1ZRzdXVl8ydUtXZjdMdHdEVW4teHUtTi1HSGliLUxud21TX0NtcGVkRFBHNkZ1WTlNczR4OUJfUVluc1BoV09oWS1scUdsNnB5d1U5M1huX3k4QzAyNldtb2hybktYN2xKZ1NTNWFsaWwzV3pCRVhkaGR5eTNlV1d6ZzFfaFZTT0E4UjRpQ3pKdEZxUlJ6UFZXM3laUndyWEk2NlBXLUpoajVhZzVwQXpWVzUtVjVNZFBwdWdQa3AxZC1KdGdqNnhibjN4dmFYb2cxcEVwc1g5R09zRUdINUZtOE5QRjVUU0dpZy1QVl9odnFtVDNuWFZLSURtMXlSMlhRNTBWSVFJbEdOOWpfVWV0SmdRWDdlUXZZWE8xRUxDN1I0aEN6MHYwNzM1cmpJS0ZpMnBYWkxfb3FsbEV1VnlqWGxqdVJ6SHlwSjAzRlMycTBaQ295NXNnZERpUnJQcjhrUUd3bkI4bDVzRmxQblhkaFJPTTdISnVUQmhET3BOMTM4bjVvUEc2VmZhb2lrR1FyTUl2RWNEeGg0U0dsNnV6eU5zOUxiNDY5SXBxR0hBS00wOTgyWTFnWkQyaEtLVUloT3ZxZGh0RWVGRmJzenFsaUtfZENQM0JzdkVVeTdXR3hUSmJST1NBMUI1NkVFWncwNW5JZVVLX1p1RXdqVnFfQWpvQ08yQjZhN1NkTkpTSnUxOVRXZXE0WFEtZWxhZW1NNXYtQ2sya0VGLURmS01lMkctNVY3c2ZhN0ZGRFgwWHlabTFkeS1hcUZ1dDZ3cnpPQ3hha2IzVE11M0pqbklmU0diczBqTFBNZC1QZGp6VzNTSnJVSjJoWkJUQjVORG4tYUJmMEJtSUNUdVpEaGt6OTM3TjFOdVhXUHItZjRtZ25nU3NhZC1sVTVXNTRDTmxZbnlfeHNsdkpuMXhUYnE1MnpVQ0ZOclRWM1M4eHdXTzRXbFRZZVQtTS1iRVdXVWZMSGotcWg3MUxUYTFnSEEtanBCRHlZRUNIdGdpUFhsYjdYUndCZnRITzhMZVJ1dHFoVlVNb0duVjlxd0U4OGRuQVV3MG90R0hiYW5MWkxWVklzbWFRNzBfSUNrdzc5bVdtTXg0dExEYnRCaDI3c1I4TWFwLXZKR0wxSjRZYjZIV3ZqZjNqTWhFT0RGSDVMc1A1UzY2bDBiMGFSUy1fNVRQRzRJWDVydUpqb1ZfSHNVbldVeUN2YlAxSW5WVDdxVzJ1WHpLeUdmb0xWMDNHN05oQzY3YnhvUUdhS2xaOHNidkVvbTZtSHFlblhOYmwyR3NQdVJDRUdxREhWdF9ZcXhwUWxHc2hyLW5vUGhIUVhJNUNhY0hFU0ptVnI0TFVhZDE1TFBBUEstSkRoZWJ5MHJhUmZrR1ZrRlFtRGpxS1pOMmFMQjBsdjluY3FiYUU4eGJVVXlZVEpuNWdHVVhJMGtwaTdZR2NDbXd2eHpOQ09SeTV6N1BaVUpsR1pQVDBZcElJUUt6VnVpQmxSYnE4Y1BCWV9IRWdVV0p3enBGVHItdnBGN3NyNWFBWmkySnByWThsbDliSlExQmp3LVlBaDIyZXp6UnR6cU9rTzJmTDBlSVpON0tiWllMdm1oME1zTFl2S2ZYYllhQlY2VHNZRGtHUDY4U1lIVExLZTU4VzZxSTZrZHl1ZTBDc0g4SjI4WGYyZHV1bm9wQ3R2Z09ld1ZmUkN5alJGeHZKSHl1bWhQVXpNMzdjblpLcUhfSm02Qlh5S1FVN3lIcHl0NnlRPT0=
|
||||||
|
|
||||||
|
|
|
||||||
24
env_int.env
24
env_int.env
|
|
@ -31,9 +31,20 @@ APP_LOGGING_FILE_ENABLED = True
|
||||||
APP_LOGGING_ROTATION_SIZE = 10485760
|
APP_LOGGING_ROTATION_SIZE = 10485760
|
||||||
APP_LOGGING_BACKUP_COUNT = 5
|
APP_LOGGING_BACKUP_COUNT = 5
|
||||||
|
|
||||||
# Service Redirects
|
# OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs)
|
||||||
Service_MSFT_REDIRECT_URI = https://gateway-int.poweron-center.net/api/msft/auth/callback
|
Service_MSFT_AUTH_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
|
||||||
Service_GOOGLE_REDIRECT_URI = https://gateway-int.poweron-center.net/api/google/auth/callback
|
Service_MSFT_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm83T29rMDZvcV9qTG5xb1FzUkdqS1llbzRxSEJXbmpONFFtcUtfZXdtZjQybmJSMjBjMEpnRVhiOGRuczZvVFBFdVVTQV80SG9PSnRQTEpLdVViNm5wc2E5aGRLWjZ4TGF1QjVkNmdRSzBpNWNkYXVublFYclVEdEM5TVBBZWVVMW5RVWk=
|
||||||
|
Service_MSFT_AUTH_REDIRECT_URI = https://gateway-int.poweron-center.net/api/msft/auth/login/callback
|
||||||
|
Service_MSFT_DATA_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
|
||||||
|
Service_MSFT_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm83T29rMDZvcV9qTG5xb1FzUkdqS1llbzRxSEJXbmpONFFtcUtfZXdtZjQybmJSMjBjMEpnRVhiOGRuczZvVFBFdVVTQV80SG9PSnRQTEpLdVViNm5wc2E5aGRLWjZ4TGF1QjVkNmdRSzBpNWNkYXVublFYclVEdEM5TVBBZWVVMW5RVWk=
|
||||||
|
Service_MSFT_DATA_REDIRECT_URI = https://gateway-int.poweron-center.net/api/msft/auth/connect/callback
|
||||||
|
|
||||||
|
Service_GOOGLE_AUTH_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
|
||||||
|
Service_GOOGLE_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNThGeVRNd3hacThtRnE0bzlDa0JPUWQyaEd6QjlFckdsMGZjRlRfUks2bXV3aDdVRTF3LVRlZVY5WjVzSXV4ZGNnX002RDl3dkNYdGFzZkxVUW01My1wTHRCanVCLUozZEx4TlduQlB5MnpvNTR2SGlvbFl1YkhzTEtsSi1SOEo=
|
||||||
|
Service_GOOGLE_AUTH_REDIRECT_URI = https://gateway-int.poweron-center.net/api/google/auth/login/callback
|
||||||
|
Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
|
||||||
|
Service_GOOGLE_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNThGeVRNd3hacThtRnE0bzlDa0JPUWQyaEd6QjlFckdsMGZjRlRfUks2bXV3aDdVRTF3LVRlZVY5WjVzSXV4ZGNnX002RDl3dkNYdGFzZkxVUW01My1wTHRCanVCLUozZEx4TlduQlB5MnpvNTR2SGlvbFl1YkhzTEtsSi1SOEo=
|
||||||
|
Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-int.poweron-center.net/api/google/auth/connect/callback
|
||||||
|
|
||||||
# Stripe Billing (both end with _SECRET for encryption script)
|
# Stripe Billing (both end with _SECRET for encryption script)
|
||||||
STRIPE_SECRET_KEY_SECRET = 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
|
STRIPE_SECRET_KEY_SECRET = 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
|
||||||
|
|
@ -48,15 +59,8 @@ Connector_AiTavily_API_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkdkJMTDY0akhXNzZDWHVYSEt1
|
||||||
Connector_AiPrivateLlm_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGSjZ1NWh0aWc1R3Z4MHNaeS1HamtUbndhcUZFZDlqUDhjSmg5eHFfdlVkU0RsVkJ2UVRaMWs3aWhraG5jSlc0YkxNWHVmR2JoSW5ENFFCdkJBM0VienlKSnhzNnBKbTJOUTFKczRfWlQ3bWpmUkRTT1I1OGNUSTlQdExacGRpeXg=
|
Connector_AiPrivateLlm_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGSjZ1NWh0aWc1R3Z4MHNaeS1HamtUbndhcUZFZDlqUDhjSmg5eHFfdlVkU0RsVkJ2UVRaMWs3aWhraG5jSlc0YkxNWHVmR2JoSW5ENFFCdkJBM0VienlKSnhzNnBKbTJOUTFKczRfWlQ3bWpmUkRTT1I1OGNUSTlQdExacGRpeXg=
|
||||||
Connector_AiMistral_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGZTNtZ1E4TWIxSEU1OUlreUpxZkJIR0Vxcm9xRHRUbnBxbTQ1cXlkbnltWkJVdTdMYWZ4c3Fsam42TERWUTVhNzZFMU9xVjdyRGFCYml6bmZsZFd2YmJzemlrSWN6Q3o3X0NXX2xXNUQteTNONHdKYzJ5YVpLLWdhU2JhSTJQZnI=
|
Connector_AiMistral_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGZTNtZ1E4TWIxSEU1OUlreUpxZkJIR0Vxcm9xRHRUbnBxbTQ1cXlkbnltWkJVdTdMYWZ4c3Fsam42TERWUTVhNzZFMU9xVjdyRGFCYml6bmZsZFd2YmJzemlrSWN6Q3o3X0NXX2xXNUQteTNONHdKYzJ5YVpLLWdhU2JhSTJQZnI=
|
||||||
|
|
||||||
# Microsoft Service Configuration
|
|
||||||
Service_MSFT_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
|
|
||||||
Service_MSFT_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm83T29rMDZvcV9qTG5xb1FzUkdqS1llbzRxSEJXbmpONFFtcUtfZXdtZjQybmJSMjBjMEpnRVhiOGRuczZvVFBFdVVTQV80SG9PSnRQTEpLdVViNm5wc2E5aGRLWjZ4TGF1QjVkNmdRSzBpNWNkYXVublFYclVEdEM5TVBBZWVVMW5RVWk=
|
|
||||||
Service_MSFT_TENANT_ID = common
|
Service_MSFT_TENANT_ID = common
|
||||||
|
|
||||||
# Google Service configuration
|
|
||||||
Service_GOOGLE_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
|
|
||||||
Service_GOOGLE_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNThGeVRNd3hacThtRnE0bzlDa0JPUWQyaEd6QjlFckdsMGZjRlRfUks2bXV3aDdVRTF3LVRlZVY5WjVzSXV4ZGNnX002RDl3dkNYdGFzZkxVUW01My1wTHRCanVCLUozZEx4TlduQlB5MnpvNTR2SGlvbFl1YkhzTEtsSi1SOEo=
|
|
||||||
|
|
||||||
# Google Cloud Speech Services configuration
|
# Google Cloud Speech Services configuration
|
||||||
Connector_GoogleSpeech_API_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkNmVXZ1pWcHcydTF2MXF0ZGJoWHBydF85bTczTktiaEJ3Wk1vMW1mZVhDSG1yd0ZxR2ZuSGJTX0N3MWptWXFJTkNTWjh1SUVVTXI4UDVzcGdLMkU5SHJ2TUpkRlRoRWdnSldtYjNTQkh4UDJHY2xmdTdZQ1ZiMTZZcGZxS3RzaHdjV3dtVkZUcEpJcWx0b2xuQVR6ZmpoVFZPY1hNMTV2SnhDaC1IZEh4UUpLTy1ILXA4RG1zamJTbUJ4X0t2M2NkdzJPbEJxSmFpRzV3WC0wZThoVzlxcmpHZ3ZkLVlVY3REZk1vV19WQ05BOWN6cnJ4MWNYYnNiQ0FQSUVnUlpfM3BhMnlsVlZUOG5wM3pzM1lSN1UzWlZKUXRLczlHbjI1LTFvSUJ4SlVXMy1BNk43bE5Hb0RfTTVlWk9oZnFIaVg0SW5pbm9EcXRTTzU1RFlYY3dTcnpKWWNyNjN5T1BGZ0FmX253cEFncmhvZVRuM05KYzhkOEhFMFJsc2NBSEwzZVZ1R0JMOGxsekVwUE55alZaRXFrdzNWWVNGWXNmbnhKeWhQSFo2VXBTUlRPeHdvdVdncEFuOWgydEtsSUFneUN6cGVaTnBSdjNCdVJseGJFdmlMc203UFhLVlYyTENkaGg2dVN6Z2xwT1ZmTmN5bVZGUkM3ZWcyVkt2ckFUVVd3WFFwYnJjNVRobEh2SkVJbXRwUUpEOFJKQ1NUc0Q4NHNqUFhPSDh5cTV6MEcwSDEwRUJCQ2JiTTJlOE5nd3pMMkJaQ1dVYjMwZVVWWnlETmp2dkZ3aXEtQ29WNkxZTFkzYUkxdTlQUU1OTnhWWU12YU9MVnJQa1d2ZjRtUlhneTNubEMxTmp1eUNPOThSMlB3Y1F0T2tCdFNsNFlKalZPV25yR2QycVBUb096RmZ1V0FTaGsxLV9FWDBmenBIOXpMdGpLcUc0TWRoY2hlMFhYTzlET1ZRekw0ZHNwUVBQdVJBX2h6Q2ZzWVZJWTNybTJiekp3WmhmWF9SUFBXQzlqUjctcVlHWWVMZWVQallzR0JGTVF0WmtnWlg1aTM1bFprNVExZXY5dnNvWF93UjhwbkJ3RzNXaVJ2d2RRU3JJVlBvaVh4eTlBRUtqWkJia3dJQVVBV2Nqdm9FUTRUVW1TaHp2ZUwxT0N2ZndxQ2Nka1RYWXF0LWxIWFE0dTFQcVhncFFPM0hFdUUtYlFnemx3WkF4bjA1aDFULUdrZlVZbEJtRGRCdjJyVkdJSXozd0I0dF9zbWhOeHFqRDA4T1NVaWR5cjBwSVgwbllPU294NjZGTnM1bFhIdGpNQUxFOENWd3FCbGpSRFRmRXotQnU0N2lCVEU5RGF6Qi10S2U2NGdadDlrRjZtVE5oZkw5ZWFjXzhCTmxXQzNFTFgxRXVYY3J3YkxnbnlBSm9PY3h4MlM1NVFQbVNDRW5Ld1dvNWMxSmdoTXJuaE1pT2VFeXYwWXBHZ29MZDVlN2lwUUNIeGNCVVdQVi1rRXdJMWFncUlPTXR0MmZVQ1l0d09mZTdzWGFBWUJMUFd3b0RSOU8zeER2UWpNdzAxS0ZJWnB5S3FJdU9wUDJnTTNwMWw3VFVqVXQ3ZGZnU1RkUktkc0NhUHJ0SGFxZ0lVWDEzYjNtU2JfMGNWM1Y0dHlCTzNESEdENC1jUWF5MVppRzR1QlBNSUJySjFfRi1ENHEwcmJ4S3hQUFpXVHA0TG9DZWdoUlo5WnNSM1lCZm1KbEs2ak1yUUU4Wk9JcVJGUkJwc0NvUkMyTjhoTWxtZmVQeDREZVRKZkhYN2duLVNTeGZzdFdBVnhEandJSXB5QjM0azF0ckI3Tk1wSzFhNGVOUVRrNjU0cG9JQ29pN09xOFkwR1lMTlktaGp4TktxdTVtTnNEcldsV2pEZm5nQWpJc2hxY0hjQnVSWUR5VVdaUXBHWUloTzFZUC1oNzJ4UjZ1dnpLcDJxWEZtQlNIMWkzZ0hXWXdKeC1iLXdZWVJhcU04VFlpMU5pd2ZIdTdCdkVWVFVBdmJuRk16bEFFQTh4alBrcTV2RzliT2hGdTVPOXlRMjFuZktiRTZIamQ1VFVqS0hRTXhxcU1mdkgyQ1NjQmZfcjl4c3NJd0RIeDVMZUFBbHJqdEJxWWl3aWdGUEQxR3ZnMkNGdVB4RUxkZi1xOVlFQXh1NjRfbkFEaEJ5TVZlUGFrWVhSTVRPeGxqNlJDTHNsRWRrei1pYjhnUmZrb3BvWkQ2QXBzYjFHNXZoWU1LSExhLWtlYlJTZlJmYUM5Y1Rhb1pkMVYyWTByM3NTS0VXMG1ybm1BTVN2QXRYaXZqX2dKSkZrajZSS2cyVlNOQnd5Y29zMlVyaWlNbTJEb3FuUFFtbWNTNVpZTktUenFZSl91cVFXZjRkQUZyYmtPczU2S1RKQ19ONGFOTHlwX2hOOEE1UHZEVjhnT0xxRjMxTEE4SHhRbmlmTkZwVXJBdlJDbU5oZS05SzI4QVhEWDZaN2ZiSlFwUGRXSnB5TE9MZV9ia3pYcmZVa1dicG5FMHRXUFZXMWJQVDAwOEdDQzJmZEl0ZDhUOEFpZXZWWXl5Q2xwSmFienNCMldlb2NKb2ZRYV9KbUdHRzNUcjU1VUFhMzk1a2J6dDVuNTl6NTdpM0hGa3k0UWVtbF9pdDVsQVp2cndDLUU5dnNYOF9CLS0ySXhBSFdCSnpqV010bllBb3U0cEZZYVF5R2tSNFM5NlRhdS1fb1NqbDBKMkw0V2N0VEZhNExtQlR3ckZ3cVlCeHVXdXJ6X0s4cEtsaG5rVUxCN2RRbHQxTmcyVFBqYUxyOHJzeFBXVUJaRHpXbUoxdHZzMFBzQk1UTUFvX1pGNFNMNDFvZWdTdEUtMUNKMXNIeVlvQk1CeEdpZVdmN0tsSDVZZHJXSGt5c2o2MHdwSTZIMVBhRzM1eU43Q2FtcVNidExxczNJeUx5U2RuUG5EeHpCTlg2SV9WNk1ET3BRNXFuc0pNWlVvZUYtY21oRGtJSmwxQ09QbHBUV3BuS3B5NE9RVkhfellqZjJUQ0diSV94QlhQWmdaaC1TRWxsMUVWSXB0aE1McFZDZDNwQUVKZ2t5cXRTXzlRZVJwN0pZSnJSV21XMlh0TzFRVEl0c2I4QjBxOGRCYkNxek04a011X1lrb2poQ3h2LUhKTGJiUlhneHp5QWFBcE5nMElkNTVzM3JGOWtUQ19wNVBTaVVHUHFDNFJnNXJaWDNBSkMwbi1WbTdtSnFySkhNQl9ZQjZrR2xDcXhTRExhMmNHcGlyWjR3ZU9SSjRZd1l4ZjVPeHNiYk53SW5SYnZPTzNkd1lnZmFseV9tQ3BxM3lNYVBHT0J0elJnMTByZ3VHemxta0tVQzZZRllmQ2VLZ1ZCNDhUUTc3LWNCZXBMekFwWW1fQkQ1NktzNGFMYUdYTU0xbXprY1FONUNlUHNMY3h2NFJMMmhNa3VNdzF4TVFWQk9odnJUMjFJMVd3Z2N6Sms5aEM2SWlWZFViZ0JWTEpUWWM5NmIzOS1oQmRqdkt1NUUycFlVcUxERUZGbnZqTUxIYnJmMDBHZDEzbnJsWEEzSUo3UmNPUDg1dnRUU1FzcWtjTWZwUG9zM0JTY3RqMDdST2UxcXFTM0d0bGkwdFhnMk5LaUlxNWx3V1pLaVlLUFJXZzBzVl9Ia1V1OHdYUEFWOU50UndycGtCdzM0Q0NQamp2VTNqbFBLaGhsbUk5dUI5MjU5OHVySk1oY0drUWtXUloyVVRvOWJmbUVYRzFVeWNQczh2NXJCeVppRlZiWDNJaDhOSmRmX2lURTNVS3NXQXFZT1QtUmdvMWJoVWYxU3lqUUJhbzEyX3I3TXhwbm9wc1FoQ1ZUTlNBRjMyQTBTY2tzbHZ3RFUtTjVxQ0o1QXRTVks2WENwMGZCRGstNU1jN3FhUFJCQThyaFhhMVRsbnlSRXNGRmt3Yk01X21ldmV3bTItWm1JaGpZQWZROEFtT1d1UUtPQlhYVVFqT2NxLUxQenJHX3JfMEdscDRiMXcyZ1ZmU3NFMzVoelZJaDlvT0ZoRGQ2bmtlM0M5ZHlCd2ZMbnRZRkZUWHVBUEx4czNfTmtMckh5eXZrZFBzOEItOGRYOEhsMzBhZ0xlOWFjZzgteVBsdnpPT1pYdUxnbFNXYnhKaVB6QUxVdUJCOFpvU2x2c1FHZV94MDBOVWJhYkxISkswc0U5UmdPWFJLXzZNYklHTjN1QzRKaldKdEVHb0pOU284N3c2LXZGMGVleEZ5NGZ6OGV1dm1tM0J0aTQ3VFlNOEJrdEh3PT0=
|
Connector_GoogleSpeech_API_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkNmVXZ1pWcHcydTF2MXF0ZGJoWHBydF85bTczTktiaEJ3Wk1vMW1mZVhDSG1yd0ZxR2ZuSGJTX0N3MWptWXFJTkNTWjh1SUVVTXI4UDVzcGdLMkU5SHJ2TUpkRlRoRWdnSldtYjNTQkh4UDJHY2xmdTdZQ1ZiMTZZcGZxS3RzaHdjV3dtVkZUcEpJcWx0b2xuQVR6ZmpoVFZPY1hNMTV2SnhDaC1IZEh4UUpLTy1ILXA4RG1zamJTbUJ4X0t2M2NkdzJPbEJxSmFpRzV3WC0wZThoVzlxcmpHZ3ZkLVlVY3REZk1vV19WQ05BOWN6cnJ4MWNYYnNiQ0FQSUVnUlpfM3BhMnlsVlZUOG5wM3pzM1lSN1UzWlZKUXRLczlHbjI1LTFvSUJ4SlVXMy1BNk43bE5Hb0RfTTVlWk9oZnFIaVg0SW5pbm9EcXRTTzU1RFlYY3dTcnpKWWNyNjN5T1BGZ0FmX253cEFncmhvZVRuM05KYzhkOEhFMFJsc2NBSEwzZVZ1R0JMOGxsekVwUE55alZaRXFrdzNWWVNGWXNmbnhKeWhQSFo2VXBTUlRPeHdvdVdncEFuOWgydEtsSUFneUN6cGVaTnBSdjNCdVJseGJFdmlMc203UFhLVlYyTENkaGg2dVN6Z2xwT1ZmTmN5bVZGUkM3ZWcyVkt2ckFUVVd3WFFwYnJjNVRobEh2SkVJbXRwUUpEOFJKQ1NUc0Q4NHNqUFhPSDh5cTV6MEcwSDEwRUJCQ2JiTTJlOE5nd3pMMkJaQ1dVYjMwZVVWWnlETmp2dkZ3aXEtQ29WNkxZTFkzYUkxdTlQUU1OTnhWWU12YU9MVnJQa1d2ZjRtUlhneTNubEMxTmp1eUNPOThSMlB3Y1F0T2tCdFNsNFlKalZPV25yR2QycVBUb096RmZ1V0FTaGsxLV9FWDBmenBIOXpMdGpLcUc0TWRoY2hlMFhYTzlET1ZRekw0ZHNwUVBQdVJBX2h6Q2ZzWVZJWTNybTJiekp3WmhmWF9SUFBXQzlqUjctcVlHWWVMZWVQallzR0JGTVF0WmtnWlg1aTM1bFprNVExZXY5dnNvWF93UjhwbkJ3RzNXaVJ2d2RRU3JJVlBvaVh4eTlBRUtqWkJia3dJQVVBV2Nqdm9FUTRUVW1TaHp2ZUwxT0N2ZndxQ2Nka1RYWXF0LWxIWFE0dTFQcVhncFFPM0hFdUUtYlFnemx3WkF4bjA1aDFULUdrZlVZbEJtRGRCdjJyVkdJSXozd0I0dF9zbWhOeHFqRDA4T1NVaWR5cjBwSVgwbllPU294NjZGTnM1bFhIdGpNQUxFOENWd3FCbGpSRFRmRXotQnU0N2lCVEU5RGF6Qi10S2U2NGdadDlrRjZtVE5oZkw5ZWFjXzhCTmxXQzNFTFgxRXVYY3J3YkxnbnlBSm9PY3h4MlM1NVFQbVNDRW5Ld1dvNWMxSmdoTXJuaE1pT2VFeXYwWXBHZ29MZDVlN2lwUUNIeGNCVVdQVi1rRXdJMWFncUlPTXR0MmZVQ1l0d09mZTdzWGFBWUJMUFd3b0RSOU8zeER2UWpNdzAxS0ZJWnB5S3FJdU9wUDJnTTNwMWw3VFVqVXQ3ZGZnU1RkUktkc0NhUHJ0SGFxZ0lVWDEzYjNtU2JfMGNWM1Y0dHlCTzNESEdENC1jUWF5MVppRzR1QlBNSUJySjFfRi1ENHEwcmJ4S3hQUFpXVHA0TG9DZWdoUlo5WnNSM1lCZm1KbEs2ak1yUUU4Wk9JcVJGUkJwc0NvUkMyTjhoTWxtZmVQeDREZVRKZkhYN2duLVNTeGZzdFdBVnhEandJSXB5QjM0azF0ckI3Tk1wSzFhNGVOUVRrNjU0cG9JQ29pN09xOFkwR1lMTlktaGp4TktxdTVtTnNEcldsV2pEZm5nQWpJc2hxY0hjQnVSWUR5VVdaUXBHWUloTzFZUC1oNzJ4UjZ1dnpLcDJxWEZtQlNIMWkzZ0hXWXdKeC1iLXdZWVJhcU04VFlpMU5pd2ZIdTdCdkVWVFVBdmJuRk16bEFFQTh4alBrcTV2RzliT2hGdTVPOXlRMjFuZktiRTZIamQ1VFVqS0hRTXhxcU1mdkgyQ1NjQmZfcjl4c3NJd0RIeDVMZUFBbHJqdEJxWWl3aWdGUEQxR3ZnMkNGdVB4RUxkZi1xOVlFQXh1NjRfbkFEaEJ5TVZlUGFrWVhSTVRPeGxqNlJDTHNsRWRrei1pYjhnUmZrb3BvWkQ2QXBzYjFHNXZoWU1LSExhLWtlYlJTZlJmYUM5Y1Rhb1pkMVYyWTByM3NTS0VXMG1ybm1BTVN2QXRYaXZqX2dKSkZrajZSS2cyVlNOQnd5Y29zMlVyaWlNbTJEb3FuUFFtbWNTNVpZTktUenFZSl91cVFXZjRkQUZyYmtPczU2S1RKQ19ONGFOTHlwX2hOOEE1UHZEVjhnT0xxRjMxTEE4SHhRbmlmTkZwVXJBdlJDbU5oZS05SzI4QVhEWDZaN2ZiSlFwUGRXSnB5TE9MZV9ia3pYcmZVa1dicG5FMHRXUFZXMWJQVDAwOEdDQzJmZEl0ZDhUOEFpZXZWWXl5Q2xwSmFienNCMldlb2NKb2ZRYV9KbUdHRzNUcjU1VUFhMzk1a2J6dDVuNTl6NTdpM0hGa3k0UWVtbF9pdDVsQVp2cndDLUU5dnNYOF9CLS0ySXhBSFdCSnpqV010bllBb3U0cEZZYVF5R2tSNFM5NlRhdS1fb1NqbDBKMkw0V2N0VEZhNExtQlR3ckZ3cVlCeHVXdXJ6X0s4cEtsaG5rVUxCN2RRbHQxTmcyVFBqYUxyOHJzeFBXVUJaRHpXbUoxdHZzMFBzQk1UTUFvX1pGNFNMNDFvZWdTdEUtMUNKMXNIeVlvQk1CeEdpZVdmN0tsSDVZZHJXSGt5c2o2MHdwSTZIMVBhRzM1eU43Q2FtcVNidExxczNJeUx5U2RuUG5EeHpCTlg2SV9WNk1ET3BRNXFuc0pNWlVvZUYtY21oRGtJSmwxQ09QbHBUV3BuS3B5NE9RVkhfellqZjJUQ0diSV94QlhQWmdaaC1TRWxsMUVWSXB0aE1McFZDZDNwQUVKZ2t5cXRTXzlRZVJwN0pZSnJSV21XMlh0TzFRVEl0c2I4QjBxOGRCYkNxek04a011X1lrb2poQ3h2LUhKTGJiUlhneHp5QWFBcE5nMElkNTVzM3JGOWtUQ19wNVBTaVVHUHFDNFJnNXJaWDNBSkMwbi1WbTdtSnFySkhNQl9ZQjZrR2xDcXhTRExhMmNHcGlyWjR3ZU9SSjRZd1l4ZjVPeHNiYk53SW5SYnZPTzNkd1lnZmFseV9tQ3BxM3lNYVBHT0J0elJnMTByZ3VHemxta0tVQzZZRllmQ2VLZ1ZCNDhUUTc3LWNCZXBMekFwWW1fQkQ1NktzNGFMYUdYTU0xbXprY1FONUNlUHNMY3h2NFJMMmhNa3VNdzF4TVFWQk9odnJUMjFJMVd3Z2N6Sms5aEM2SWlWZFViZ0JWTEpUWWM5NmIzOS1oQmRqdkt1NUUycFlVcUxERUZGbnZqTUxIYnJmMDBHZDEzbnJsWEEzSUo3UmNPUDg1dnRUU1FzcWtjTWZwUG9zM0JTY3RqMDdST2UxcXFTM0d0bGkwdFhnMk5LaUlxNWx3V1pLaVlLUFJXZzBzVl9Ia1V1OHdYUEFWOU50UndycGtCdzM0Q0NQamp2VTNqbFBLaGhsbUk5dUI5MjU5OHVySk1oY0drUWtXUloyVVRvOWJmbUVYRzFVeWNQczh2NXJCeVppRlZiWDNJaDhOSmRmX2lURTNVS3NXQXFZT1QtUmdvMWJoVWYxU3lqUUJhbzEyX3I3TXhwbm9wc1FoQ1ZUTlNBRjMyQTBTY2tzbHZ3RFUtTjVxQ0o1QXRTVks2WENwMGZCRGstNU1jN3FhUFJCQThyaFhhMVRsbnlSRXNGRmt3Yk01X21ldmV3bTItWm1JaGpZQWZROEFtT1d1UUtPQlhYVVFqT2NxLUxQenJHX3JfMEdscDRiMXcyZ1ZmU3NFMzVoelZJaDlvT0ZoRGQ2bmtlM0M5ZHlCd2ZMbnRZRkZUWHVBUEx4czNfTmtMckh5eXZrZFBzOEItOGRYOEhsMzBhZ0xlOWFjZzgteVBsdnpPT1pYdUxnbFNXYnhKaVB6QUxVdUJCOFpvU2x2c1FHZV94MDBOVWJhYkxISkswc0U5UmdPWFJLXzZNYklHTjN1QzRKaldKdEVHb0pOU284N3c2LXZGMGVleEZ5NGZ6OGV1dm1tM0J0aTQ3VFlNOEJrdEh3PT0=
|
||||||
|
|
||||||
|
|
|
||||||
24
env_prod.env
24
env_prod.env
|
|
@ -31,9 +31,20 @@ APP_LOGGING_FILE_ENABLED = True
|
||||||
APP_LOGGING_ROTATION_SIZE = 10485760
|
APP_LOGGING_ROTATION_SIZE = 10485760
|
||||||
APP_LOGGING_BACKUP_COUNT = 5
|
APP_LOGGING_BACKUP_COUNT = 5
|
||||||
|
|
||||||
# Service Redirects
|
# OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs)
|
||||||
Service_MSFT_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/msft/auth/callback
|
Service_MSFT_AUTH_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
|
||||||
Service_GOOGLE_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/google/auth/callback
|
Service_MSFT_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBESkk2T25scFU1T1pNd2FENTFRM3kzcEpSXy1HT0trQkR2Wnl3U3RYbExzRy1YUTkxd3lPZE84U2lhX3FZanp5TjhYRGluLXVjU3hjaWRBUnZLbVhtRDItZ3FxNXJ3MUxicUZTXzJWZVNrR0VKN3ZlNEtET1ppOFk0MzNmbkwyRmROUk4=
|
||||||
|
Service_MSFT_AUTH_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/msft/auth/login/callback
|
||||||
|
Service_MSFT_DATA_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
|
||||||
|
Service_MSFT_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBESkk2T25scFU1T1pNd2FENTFRM3kzcEpSXy1HT0trQkR2Wnl3U3RYbExzRy1YUTkxd3lPZE84U2lhX3FZanp5TjhYRGluLXVjU3hjaWRBUnZLbVhtRDItZ3FxNXJ3MUxicUZTXzJWZVNrR0VKN3ZlNEtET1ppOFk0MzNmbkwyRmROUk4=
|
||||||
|
Service_MSFT_DATA_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/msft/auth/connect/callback
|
||||||
|
|
||||||
|
Service_GOOGLE_AUTH_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
|
||||||
|
Service_GOOGLE_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o=
|
||||||
|
Service_GOOGLE_AUTH_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/google/auth/login/callback
|
||||||
|
Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
|
||||||
|
Service_GOOGLE_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o=
|
||||||
|
Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/google/auth/connect/callback
|
||||||
|
|
||||||
# Stripe Billing (both end with _SECRET for encryption script)
|
# Stripe Billing (both end with _SECRET for encryption script)
|
||||||
STRIPE_SECRET_KEY_SECRET = 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
|
STRIPE_SECRET_KEY_SECRET = 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
|
||||||
|
|
@ -48,15 +59,8 @@ Connector_AiTavily_API_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3NmItcDh6V0JpcE5Jc0NlUWZ
|
||||||
Connector_AiPrivateLlm_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGanZ6U3pzZWkwXzVPWGtIQ040XzFrTXc5QWRnazdEeEktaUJ0akJmNnEzbWUzNHczLTJfc2dIdzBDY0FTaXZYcDhxNFdNbTNtbEJTb2VRZ0ZYd05hdlNLR1h6SUFzVml2Z1FLY1BjTl90UWozUGxtak1URnhhZmNDRWFTb0dKVUo=
|
Connector_AiPrivateLlm_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGanZ6U3pzZWkwXzVPWGtIQ040XzFrTXc5QWRnazdEeEktaUJ0akJmNnEzbWUzNHczLTJfc2dIdzBDY0FTaXZYcDhxNFdNbTNtbEJTb2VRZ0ZYd05hdlNLR1h6SUFzVml2Z1FLY1BjTl90UWozUGxtak1URnhhZmNDRWFTb0dKVUo=
|
||||||
Connector_AiMistral_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGc2tQc2lvMk1YZk01Q1dob1U5cnR0dG03WWE3WkpoOWo0SEpvLU9Rc2lCNDExdy1wZExaN3lpT2FEQkxnaHRmWmZUUUZUUUJmblZreGlpaFpOdnFhbzlEd1RsVVJtX216cmhxTm5BcTN2eUZ2T054cDE5bmlEamJ3NGR6MVpFQnA=
|
Connector_AiMistral_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGc2tQc2lvMk1YZk01Q1dob1U5cnR0dG03WWE3WkpoOWo0SEpvLU9Rc2lCNDExdy1wZExaN3lpT2FEQkxnaHRmWmZUUUZUUUJmblZreGlpaFpOdnFhbzlEd1RsVVJtX216cmhxTm5BcTN2eUZ2T054cDE5bmlEamJ3NGR6MVpFQnA=
|
||||||
|
|
||||||
# Microsoft Service Configuration
|
|
||||||
Service_MSFT_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
|
|
||||||
Service_MSFT_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBESkk2T25scFU1T1pNd2FENTFRM3kzcEpSXy1HT0trQkR2Wnl3U3RYbExzRy1YUTkxd3lPZE84U2lhX3FZanp5TjhYRGluLXVjU3hjaWRBUnZLbVhtRDItZ3FxNXJ3MUxicUZTXzJWZVNrR0VKN3ZlNEtET1ppOFk0MzNmbkwyRmROUk4=
|
|
||||||
Service_MSFT_TENANT_ID = common
|
Service_MSFT_TENANT_ID = common
|
||||||
|
|
||||||
# Google Service configuration
|
|
||||||
Service_GOOGLE_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
|
|
||||||
Service_GOOGLE_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o=
|
|
||||||
|
|
||||||
# Google Cloud Speech Services configuration
|
# Google Cloud Speech Services configuration
|
||||||
Connector_GoogleSpeech_API_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4NFQxaF9uN3h1cVB6dnZid1c1R1VfNDlSQ1NHMEVDZWtKanpMQ29CLXc1MXBqRm1hQ0YtWVhaejBMY1ZTOEFEVlpWQ3hrYkFza1E2RDNsYkdMMndNR0VGNTMwVDRGdURJY3hyaVFxVjEtSEYwNHJzeWM3WmlpZW9jU2E3NTgycEV2allqQ3dJRTNyRFAzaDJ6dklKeXpNRkJhYjFzUkptN2dpbkNpMklrcGxuZl9vTkt3T0JvNm1YTXd5UlkwZWptUXdWVFpnV2J4X3J2WUhIUlFkSElFVnlqMnlJRnNHTnlpMWs2R1dZc2ROWjNYZG85cndmd1E5cUZnVmZRYnVjTG43dXFmSWd2bGFfVWFWSmtpWkpndWNlSUNwcnFNU2NqZXFaV0xsY3l3SElLRkVHcHZGZERKV1ltcGhTS0dhTko1VTJLYzNoZjRkSGVEX3dTMWVVTmdDczV5cE1JQUdSbUJGUm11eFhTVjJHbkt0SzB4UG1Dc2xmbnp1Y041Y2RTeWRuWGdmQy1sTGx0MGtnM2VJQ3EyLXViRlNhTU9ybzZkR1N1bXE5SXhlZENWRFpWSGlYOWx4SUQ3UlR0ZEVxQkxNakRUVFRiUmFnbklOalphLUZkRFVVaXBRUk5NZW5PaUZydTFmQkNPSTdTVTNZd0plWXllNVFJdmN4MVcyTGlwMGFtVjBzOGRxR1FjbzhfYW5zdTB0ZEZBTTJhakltazh1dktNMUZsOUItdFdTb1pIaUxySllXNkdlY20zUS0wTnpFNTB2SU5acG1VcXhyaHBmME8takw3RDh5T043T2VGOV92TzNya2pWSlpYVjZDdXlZcjM3a0hPTlhkaW9oQmxqQlpGRFYyTTY4WmZmT3k4Tk1tdXRuSGdTUVpNT2NKenhXb05PdXBfSEdhMTNxNjdpNXlKUUI2YUgydFFPX1VvXzVJb0UxWTU2YVNiNDQ0QndZanhMMHR1cGdHWGhvcEg1QXEtSXZJdTdZUE12ZEVVWkF4QmtsQS1GYnY3SFIxSHlsOGVfcEpGS1A4QUVEQWNEOFZYYlljQ3ByTU03YU16Y0UzUnJQZEprSWNjT1ZXVEtDWi03Y3ZzRVdYUTlabXJISEo5THRHVXVuM0xqbzA4bGVlZVpOMk1QMmptb21tV0pTMlVoOXdWVU95UW1iQmttc2w1RG9mMWwxXzg1T2IxYUVmTUJEZkpUdTFDTzZ3RlBFeUFiX01iRTZNWkNaSG45TkFOM2pzbUJRZ2N0VFpoejJUTG1RODY3TzZpSzVkYUQzaEpfY2pSTkRzU0VpanlkdXVQQmJ2WU5peno4QWNLTDVxZTlhSHI3NnNiM0k0Y3JkQ0xaOU05bGtsQl8zQklvaktWSDZ4aVp2MHlYelJuUDJyTU9CZC1OZjJxNFc1dDcwSUlxaVh1LTMyWWFwU0IwUU9kOUFpMWpnOERtLTh1VmJiNGVwcXBMbU5fMjVZc0hFbmxQT2puSFd1ZGpyTkphLU5sVlBZWWxrWEZrWGJQWmVkN19tZFZfZ1l1V3pSWlA0V0ZxM2lrWnl2NU9WeTdCbDROSmhfeENKTFhMVXk1d195S2JMUFJoRXZjcVo4V2g0MTNKRnZhUE1wRkNPM3FZOGdVazJPeW5PSGpuZnFGTTdJMkRnam5rUlV6NFlqODlIelRYaEN5VjdJNnVwbllNODNCTFRHMWlXbmM1VlRxbXB3Wm9LRjVrQUpjYzRNMThUMWwwSVhBMUlyamtPZnE4R0o4bEdHay1zMjR5RDJkZ1lYRHZaNHVHU2otR3ZpN25LZlEySEU0UmdTNzJGVHNWQXMyb0dVMV9WUE13ODhZWUFaakxGOWZieGNXZkNYRnV5djEyWTZLcmdrajRBLU1rS1Z0VVRkOWlDMU9fMGVmYXFhZXJGMUhpNkdmb2hkbzZ1OWV6VlNmVzNISjVYTFh6SjJNdWR5MWZidE8yVEo2dnRrZXhMRXBPczUwTG13OGhNUVpIQm0zQmRKRnJ0Nl8wNW1Ob0dHRDVpU0NWREV3TkY2SjktdVBkMFU1ZXBmSFpHQ3FHNTRZdTJvaExpZVEtLTU4YTVyeFBpNDdEajZtWUc4c1dBeUJqQ3NIY1NLS0FIMUxGZzZxNFNkOG9ORGNHWWJCVnZuNnJVTEtoQi1mRTZyUl81ZWJJMi1KOGdERzBhNVRZeHRYUUlqY2JvMFlaNHhWMU9pWFFiZjdaLUhkaG15TTBPZVlkS2R5UVdENTI4QVFiY1RJV0ZNZnlpVWxfZmlnN1BXbGdrbjFGUkhzYl9qeHBxVVJacUE4bjZETENHVFpSamh0NVpOM2hMYTZjYzBuS3J0a3hhZGxSM1V5UHd2OTU3ZHY0Yy1xWDBkWUk0Ymp0MWVrS3YzSktKODhQZnY3QTZ1Wm1VZkZJbS1jamdreks1ZlhpQjFOUDFiOHJ2Nm9NcmdTdU5LQXV2RkZWZEFNZnVKUjVwcVY3dDdhQnpmRVJ6SmlvVXpDM0ZiYXh5bGE2X04tTE9qZ3BiTnN3TF9ZaFRxSUpjNjB1dXZBcy1TZHRHTjFjSUR3WUl4cE9VNzB5Rkk4U3Z1SVZYTl9sYXlZVk83UnFrMlVmcnBpam9lRUlCY19DdVJwOXl2TVVDV1pMRFZTZk9MY3Z1eXA0MnhGazc5YllQaWtOeTc4NjlOa2lGY05RRzY1cG9nbGpYelc4c3FicWxWRkg0YzRSamFlQ19zOU14YWJreU9pNDREZVJ3a0REMUxGTzF1XzI1bEF3VXVZRjlBeWFiLXJsOXgza3VZem1WckhWSnVNbDBNcldadU8xQ3RwOTl5NGgtVlR0QklCLWl5WkE4V1FlQTBCOVU1RE9sQlRrYUNZOGdfUmEwbEZvUTFGUEFWVmQ4V1FhOU9VNjZqemRpZm1sUDhZQTJ0YVBRbWZldkF5THV4QXpfdUtNZ0tlcGdSRFM3c0lDOTNQbnBxdmxYYWNpTmI3MW9BMlZIdTQ5RldudHpNQWQ5NDNPLVVTLXVVNzdHZXh4UXpZa3dVa2J4dTFDV1RkYjRnWXU2M3lJekRYWGNMcWU5OVh6U2xZWDh6MmpqcnpiOHlnMjA5S3RFQm1NZjNSM21adkVnTUpSYVhkTzNkNnJCTmljY0x1cl9kMkx3UHhySjZEdHREanZERzNEUTFlTkR0NWlBczAtdmFGTjdZNVpTMlkxV2czYW5RN2lqemg4eUViZDV6RjdKNXdFcUlvcVhoNkJ6eVJkR1pua1hnNzQwOEs2TXJYSlpGcW9qRDU2QjBOWFFtdXBJRkRKbmdZUF9ZSmRPVEtvUjVhLTV1NjdXQjRhS0duaEtJb2FrQnNjUTRvdFMxdkdTNk1NYlFHUFhhYTJ1eUN3WHN4UlJ4UjdrZjY0SzFGYWVFN1k0cGJnc1RjNmFUenR4NHljbVhablZSWHZmUVN3cXRHNjhsX1BSZWEzdTJUZFA0S2pTaU9YMnZIQ1ZPcGhWMFJqZkVEMWRMR1h3SnU0Z2FzZ3VGM3puNzdhVjhaQXNIWHFsbjB0TDVYSFdSNV9rdWhUUUhSZHBGYkJIVDB5SDdlMC13QTVnS0g5Qkg5RGNxSGJlelVndUhPcEQ0QkRKMTJTZUM1OXJhVm0zYjU0OVY2dk9MQVBheklIQXpVNW9Yc0ROVjEzaFZTWmVxYlBWMlNlSzladzJ6TmNuMG5FVVZkN1VZN1pfS2ZHa0lQcE80S24wSnQtVlJVV09OVWJ3M09YMkZpV2ktVF9ENHhKU2dfYUQ2aUVyamk0VHJHQmVfVHU4clpUTFoteW5aSWRPV1M0RDRMTms4NGRoYmJfVE82aUl2X3VieVJOdDhBQmRwdzdnRTVBNzZwaW93dUlZb3ZRYUtOeG9ULWxvNVp5a0haSjdkcUhRb3d6UGIxRUpCVkVYX2d6TkRqQVozUWxkNGFoc1FXYVd2YWNkME9Qclo0bjYxMFRWTy1nbnI5NTBJNzRMMDluUXRKYTFqQUN4d0d5aHVlamN3Tkk3NWJXeXR0TW9BeUg5Vnp4Q2RnZUY3b3AtMDlrNmlrSGR0eGRtbUdUd2lFRWg4MklEeWJHN2wwZEpVSXMxNDNOWjRFS0tPdWxhMmFCckhfRENIY184aEFDZXNrRDl2dHQtQW12UnRuQXJjaDJoTUpiYkNWQUtfRG9GMUZoNWM4UnBYZ29RWWs2NHcyUm5kdTF3Vk1GeFpiRUJLaVZ2UGFjbi1jV3lMV0N2ZDl4VERPN295X01NNG56ZjZkRzZoYUtmY1E5NlVXemx2SnVfb19iSXg0R2M3Mjd1a2JRPT0=
|
Connector_GoogleSpeech_API_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4NFQxaF9uN3h1cVB6dnZid1c1R1VfNDlSQ1NHMEVDZWtKanpMQ29CLXc1MXBqRm1hQ0YtWVhaejBMY1ZTOEFEVlpWQ3hrYkFza1E2RDNsYkdMMndNR0VGNTMwVDRGdURJY3hyaVFxVjEtSEYwNHJzeWM3WmlpZW9jU2E3NTgycEV2allqQ3dJRTNyRFAzaDJ6dklKeXpNRkJhYjFzUkptN2dpbkNpMklrcGxuZl9vTkt3T0JvNm1YTXd5UlkwZWptUXdWVFpnV2J4X3J2WUhIUlFkSElFVnlqMnlJRnNHTnlpMWs2R1dZc2ROWjNYZG85cndmd1E5cUZnVmZRYnVjTG43dXFmSWd2bGFfVWFWSmtpWkpndWNlSUNwcnFNU2NqZXFaV0xsY3l3SElLRkVHcHZGZERKV1ltcGhTS0dhTko1VTJLYzNoZjRkSGVEX3dTMWVVTmdDczV5cE1JQUdSbUJGUm11eFhTVjJHbkt0SzB4UG1Dc2xmbnp1Y041Y2RTeWRuWGdmQy1sTGx0MGtnM2VJQ3EyLXViRlNhTU9ybzZkR1N1bXE5SXhlZENWRFpWSGlYOWx4SUQ3UlR0ZEVxQkxNakRUVFRiUmFnbklOalphLUZkRFVVaXBRUk5NZW5PaUZydTFmQkNPSTdTVTNZd0plWXllNVFJdmN4MVcyTGlwMGFtVjBzOGRxR1FjbzhfYW5zdTB0ZEZBTTJhakltazh1dktNMUZsOUItdFdTb1pIaUxySllXNkdlY20zUS0wTnpFNTB2SU5acG1VcXhyaHBmME8takw3RDh5T043T2VGOV92TzNya2pWSlpYVjZDdXlZcjM3a0hPTlhkaW9oQmxqQlpGRFYyTTY4WmZmT3k4Tk1tdXRuSGdTUVpNT2NKenhXb05PdXBfSEdhMTNxNjdpNXlKUUI2YUgydFFPX1VvXzVJb0UxWTU2YVNiNDQ0QndZanhMMHR1cGdHWGhvcEg1QXEtSXZJdTdZUE12ZEVVWkF4QmtsQS1GYnY3SFIxSHlsOGVfcEpGS1A4QUVEQWNEOFZYYlljQ3ByTU03YU16Y0UzUnJQZEprSWNjT1ZXVEtDWi03Y3ZzRVdYUTlabXJISEo5THRHVXVuM0xqbzA4bGVlZVpOMk1QMmptb21tV0pTMlVoOXdWVU95UW1iQmttc2w1RG9mMWwxXzg1T2IxYUVmTUJEZkpUdTFDTzZ3RlBFeUFiX01iRTZNWkNaSG45TkFOM2pzbUJRZ2N0VFpoejJUTG1RODY3TzZpSzVkYUQzaEpfY2pSTkRzU0VpanlkdXVQQmJ2WU5peno4QWNLTDVxZTlhSHI3NnNiM0k0Y3JkQ0xaOU05bGtsQl8zQklvaktWSDZ4aVp2MHlYelJuUDJyTU9CZC1OZjJxNFc1dDcwSUlxaVh1LTMyWWFwU0IwUU9kOUFpMWpnOERtLTh1VmJiNGVwcXBMbU5fMjVZc0hFbmxQT2puSFd1ZGpyTkphLU5sVlBZWWxrWEZrWGJQWmVkN19tZFZfZ1l1V3pSWlA0V0ZxM2lrWnl2NU9WeTdCbDROSmhfeENKTFhMVXk1d195S2JMUFJoRXZjcVo4V2g0MTNKRnZhUE1wRkNPM3FZOGdVazJPeW5PSGpuZnFGTTdJMkRnam5rUlV6NFlqODlIelRYaEN5VjdJNnVwbllNODNCTFRHMWlXbmM1VlRxbXB3Wm9LRjVrQUpjYzRNMThUMWwwSVhBMUlyamtPZnE4R0o4bEdHay1zMjR5RDJkZ1lYRHZaNHVHU2otR3ZpN25LZlEySEU0UmdTNzJGVHNWQXMyb0dVMV9WUE13ODhZWUFaakxGOWZieGNXZkNYRnV5djEyWTZLcmdrajRBLU1rS1Z0VVRkOWlDMU9fMGVmYXFhZXJGMUhpNkdmb2hkbzZ1OWV6VlNmVzNISjVYTFh6SjJNdWR5MWZidE8yVEo2dnRrZXhMRXBPczUwTG13OGhNUVpIQm0zQmRKRnJ0Nl8wNW1Ob0dHRDVpU0NWREV3TkY2SjktdVBkMFU1ZXBmSFpHQ3FHNTRZdTJvaExpZVEtLTU4YTVyeFBpNDdEajZtWUc4c1dBeUJqQ3NIY1NLS0FIMUxGZzZxNFNkOG9ORGNHWWJCVnZuNnJVTEtoQi1mRTZyUl81ZWJJMi1KOGdERzBhNVRZeHRYUUlqY2JvMFlaNHhWMU9pWFFiZjdaLUhkaG15TTBPZVlkS2R5UVdENTI4QVFiY1RJV0ZNZnlpVWxfZmlnN1BXbGdrbjFGUkhzYl9qeHBxVVJacUE4bjZETENHVFpSamh0NVpOM2hMYTZjYzBuS3J0a3hhZGxSM1V5UHd2OTU3ZHY0Yy1xWDBkWUk0Ymp0MWVrS3YzSktKODhQZnY3QTZ1Wm1VZkZJbS1jamdreks1ZlhpQjFOUDFiOHJ2Nm9NcmdTdU5LQXV2RkZWZEFNZnVKUjVwcVY3dDdhQnpmRVJ6SmlvVXpDM0ZiYXh5bGE2X04tTE9qZ3BiTnN3TF9ZaFRxSUpjNjB1dXZBcy1TZHRHTjFjSUR3WUl4cE9VNzB5Rkk4U3Z1SVZYTl9sYXlZVk83UnFrMlVmcnBpam9lRUlCY19DdVJwOXl2TVVDV1pMRFZTZk9MY3Z1eXA0MnhGazc5YllQaWtOeTc4NjlOa2lGY05RRzY1cG9nbGpYelc4c3FicWxWRkg0YzRSamFlQ19zOU14YWJreU9pNDREZVJ3a0REMUxGTzF1XzI1bEF3VXVZRjlBeWFiLXJsOXgza3VZem1WckhWSnVNbDBNcldadU8xQ3RwOTl5NGgtVlR0QklCLWl5WkE4V1FlQTBCOVU1RE9sQlRrYUNZOGdfUmEwbEZvUTFGUEFWVmQ4V1FhOU9VNjZqemRpZm1sUDhZQTJ0YVBRbWZldkF5THV4QXpfdUtNZ0tlcGdSRFM3c0lDOTNQbnBxdmxYYWNpTmI3MW9BMlZIdTQ5RldudHpNQWQ5NDNPLVVTLXVVNzdHZXh4UXpZa3dVa2J4dTFDV1RkYjRnWXU2M3lJekRYWGNMcWU5OVh6U2xZWDh6MmpqcnpiOHlnMjA5S3RFQm1NZjNSM21adkVnTUpSYVhkTzNkNnJCTmljY0x1cl9kMkx3UHhySjZEdHREanZERzNEUTFlTkR0NWlBczAtdmFGTjdZNVpTMlkxV2czYW5RN2lqemg4eUViZDV6RjdKNXdFcUlvcVhoNkJ6eVJkR1pua1hnNzQwOEs2TXJYSlpGcW9qRDU2QjBOWFFtdXBJRkRKbmdZUF9ZSmRPVEtvUjVhLTV1NjdXQjRhS0duaEtJb2FrQnNjUTRvdFMxdkdTNk1NYlFHUFhhYTJ1eUN3WHN4UlJ4UjdrZjY0SzFGYWVFN1k0cGJnc1RjNmFUenR4NHljbVhablZSWHZmUVN3cXRHNjhsX1BSZWEzdTJUZFA0S2pTaU9YMnZIQ1ZPcGhWMFJqZkVEMWRMR1h3SnU0Z2FzZ3VGM3puNzdhVjhaQXNIWHFsbjB0TDVYSFdSNV9rdWhUUUhSZHBGYkJIVDB5SDdlMC13QTVnS0g5Qkg5RGNxSGJlelVndUhPcEQ0QkRKMTJTZUM1OXJhVm0zYjU0OVY2dk9MQVBheklIQXpVNW9Yc0ROVjEzaFZTWmVxYlBWMlNlSzladzJ6TmNuMG5FVVZkN1VZN1pfS2ZHa0lQcE80S24wSnQtVlJVV09OVWJ3M09YMkZpV2ktVF9ENHhKU2dfYUQ2aUVyamk0VHJHQmVfVHU4clpUTFoteW5aSWRPV1M0RDRMTms4NGRoYmJfVE82aUl2X3VieVJOdDhBQmRwdzdnRTVBNzZwaW93dUlZb3ZRYUtOeG9ULWxvNVp5a0haSjdkcUhRb3d6UGIxRUpCVkVYX2d6TkRqQVozUWxkNGFoc1FXYVd2YWNkME9Qclo0bjYxMFRWTy1nbnI5NTBJNzRMMDluUXRKYTFqQUN4d0d5aHVlamN3Tkk3NWJXeXR0TW9BeUg5Vnp4Q2RnZUY3b3AtMDlrNmlrSGR0eGRtbUdUd2lFRWg4MklEeWJHN2wwZEpVSXMxNDNOWjRFS0tPdWxhMmFCckhfRENIY184aEFDZXNrRDl2dHQtQW12UnRuQXJjaDJoTUpiYkNWQUtfRG9GMUZoNWM4UnBYZ29RWWs2NHcyUm5kdTF3Vk1GeFpiRUJLaVZ2UGFjbi1jV3lMV0N2ZDl4VERPN295X01NNG56ZjZkRzZoYUtmY1E5NlVXemx2SnVfb19iSXg0R2M3Mjd1a2JRPT0=
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ from modules.shared.configuration import APP_CONFIG
|
||||||
from modules.security.rootAccess import getRootDbAppConnector, getRootUser
|
from modules.security.rootAccess import getRootDbAppConnector, getRootUser
|
||||||
from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
|
from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
|
||||||
from modules.datamodels.datamodelUam import User, AuthAuthority, AccessLevel
|
from modules.datamodels.datamodelUam import User, AuthAuthority, AccessLevel
|
||||||
from modules.datamodels.datamodelSecurity import Token
|
from modules.datamodels.datamodelSecurity import Token, TokenPurpose
|
||||||
from modules.datamodels.datamodelRbac import AccessRule
|
from modules.datamodels.datamodelRbac import AccessRule
|
||||||
|
|
||||||
# Get Config Data
|
# Get Config Data
|
||||||
|
|
@ -189,10 +189,46 @@ def _getUserBase(token: str = Depends(cookieAuth)) -> User:
|
||||||
f"Local JWT db record not active/valid: jti={tokenId}, userId={user.id}, sessionId={sessionId}"
|
f"Local JWT db record not active/valid: jti={tokenId}, userId={user.id}, sessionId={sessionId}"
|
||||||
)
|
)
|
||||||
raise credentialsException
|
raise credentialsException
|
||||||
|
elif token_authority == str(AuthAuthority.GOOGLE.value):
|
||||||
|
active_token = appInterface.findActiveTokenById(
|
||||||
|
tokenId=tokenId,
|
||||||
|
userId=user.id,
|
||||||
|
authority=AuthAuthority.GOOGLE,
|
||||||
|
sessionId=sessionId,
|
||||||
|
mandateId=None,
|
||||||
|
tokenPurpose=TokenPurpose.AUTH_SESSION.value,
|
||||||
|
)
|
||||||
|
if not active_token:
|
||||||
|
logger.info(
|
||||||
|
f"Google JWT db record not active/valid: jti={tokenId}, userId={user.id}"
|
||||||
|
)
|
||||||
|
raise credentialsException
|
||||||
|
elif token_authority == str(AuthAuthority.MSFT.value):
|
||||||
|
active_token = appInterface.findActiveTokenById(
|
||||||
|
tokenId=tokenId,
|
||||||
|
userId=user.id,
|
||||||
|
authority=AuthAuthority.MSFT,
|
||||||
|
sessionId=sessionId,
|
||||||
|
mandateId=None,
|
||||||
|
tokenPurpose=TokenPurpose.AUTH_SESSION.value,
|
||||||
|
)
|
||||||
|
if not active_token:
|
||||||
|
logger.info(
|
||||||
|
f"Microsoft JWT db record not active/valid: jti={tokenId}, userId={user.id}"
|
||||||
|
)
|
||||||
|
raise credentialsException
|
||||||
else:
|
else:
|
||||||
# No DB record for this token. If the claim says local (or missing/unknown), require DB record.
|
# No DB record for this token. If the claim says local (or missing/unknown), require DB record.
|
||||||
if normalized_authority in (None, "", str(AuthAuthority.LOCAL.value)):
|
if normalized_authority in (
|
||||||
logger.info("Local JWT without server record or missing authority claim")
|
None,
|
||||||
|
"",
|
||||||
|
str(AuthAuthority.LOCAL.value),
|
||||||
|
str(AuthAuthority.GOOGLE.value),
|
||||||
|
str(AuthAuthority.MSFT.value),
|
||||||
|
):
|
||||||
|
logger.info(
|
||||||
|
"JWT without server record or missing authority claim (local/google/msft require DB row)"
|
||||||
|
)
|
||||||
raise credentialsException
|
raise credentialsException
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
|
||||||
|
|
@ -24,10 +24,17 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
||||||
self.exempt_paths = exempt_paths or {
|
self.exempt_paths = exempt_paths or {
|
||||||
"/api/local/login",
|
"/api/local/login",
|
||||||
"/api/local/register",
|
"/api/local/register",
|
||||||
"/api/msft/login",
|
# OAuth Auth app + Data app (GET redirects / callbacks)
|
||||||
"/api/google/login",
|
"/api/msft/auth/login",
|
||||||
"/api/msft/callback",
|
"/api/msft/auth/login/callback",
|
||||||
"/api/google/callback",
|
"/api/msft/auth/connect",
|
||||||
|
"/api/msft/auth/connect/callback",
|
||||||
|
"/api/msft/adminconsent",
|
||||||
|
"/api/msft/adminconsent/callback",
|
||||||
|
"/api/google/auth/login",
|
||||||
|
"/api/google/auth/login/callback",
|
||||||
|
"/api/google/auth/connect",
|
||||||
|
"/api/google/auth/connect/callback",
|
||||||
"/api/billing/webhook/stripe", # Stripe webhook (auth via Stripe-Signature)
|
"/api/billing/webhook/stripe", # Stripe webhook (auth via Stripe-Signature)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
42
modules/auth/oauthProviderConfig.py
Normal file
42
modules/auth/oauthProviderConfig.py
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""OAuth scope sets for split Auth- vs Data-apps (Google / Microsoft)."""
|
||||||
|
|
||||||
|
# Google — Auth app only (no Gmail/Drive API scopes)
|
||||||
|
googleAuthScopes = [
|
||||||
|
"openid",
|
||||||
|
"https://www.googleapis.com/auth/userinfo.email",
|
||||||
|
"https://www.googleapis.com/auth/userinfo.profile",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Google — Data app (Gmail + Drive + identity for token responses)
|
||||||
|
googleDataScopes = [
|
||||||
|
"openid",
|
||||||
|
"https://www.googleapis.com/auth/userinfo.email",
|
||||||
|
"https://www.googleapis.com/auth/userinfo.profile",
|
||||||
|
"https://www.googleapis.com/auth/gmail.readonly",
|
||||||
|
"https://www.googleapis.com/auth/drive.readonly",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Microsoft — Auth app: Graph profile only (MSAL adds openid, profile, offline_access, …)
|
||||||
|
msftAuthScopes = [
|
||||||
|
"User.Read",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Microsoft — Data app (delegated; requires admin consent for several)
|
||||||
|
msftDataScopes = [
|
||||||
|
"User.Read",
|
||||||
|
"Mail.ReadWrite",
|
||||||
|
"Mail.Send",
|
||||||
|
"Files.ReadWrite.All",
|
||||||
|
"Sites.ReadWrite.All",
|
||||||
|
"Team.ReadBasic.All",
|
||||||
|
"OnlineMeetings.Read",
|
||||||
|
"Chat.ReadWrite",
|
||||||
|
"ChatMessage.Send",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def msftDataScopesForRefresh() -> str:
|
||||||
|
"""Space-separated scope string identical to authorization request (Token v2 refresh)."""
|
||||||
|
return " ".join(msftDataScopes)
|
||||||
|
|
@ -9,10 +9,11 @@ import logging
|
||||||
import httpx
|
import httpx
|
||||||
from typing import Optional, Dict, Any, Callable
|
from typing import Optional, Dict, Any, Callable
|
||||||
|
|
||||||
from modules.datamodels.datamodelSecurity import Token
|
from modules.datamodels.datamodelSecurity import Token, TokenPurpose
|
||||||
from modules.datamodels.datamodelUam import AuthAuthority
|
from modules.datamodels.datamodelUam import AuthAuthority
|
||||||
from modules.shared.configuration import APP_CONFIG
|
from modules.shared.configuration import APP_CONFIG
|
||||||
from modules.shared.timeUtils import getUtcTimestamp, createExpirationTimestamp, parseTimestamp
|
from modules.shared.timeUtils import getUtcTimestamp, createExpirationTimestamp, parseTimestamp
|
||||||
|
from modules.auth.oauthProviderConfig import msftDataScopesForRefresh
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -20,14 +21,14 @@ class TokenManager:
|
||||||
"""Centralized token management service"""
|
"""Centralized token management service"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# Microsoft OAuth configuration
|
# Microsoft Data-app OAuth (refresh + token exchange for connections)
|
||||||
self.msft_client_id = APP_CONFIG.get("Service_MSFT_CLIENT_ID")
|
self.msft_client_id = APP_CONFIG.get("Service_MSFT_DATA_CLIENT_ID")
|
||||||
self.msft_client_secret = APP_CONFIG.get("Service_MSFT_CLIENT_SECRET")
|
self.msft_client_secret = APP_CONFIG.get("Service_MSFT_DATA_CLIENT_SECRET")
|
||||||
self.msft_tenant_id = APP_CONFIG.get("Service_MSFT_TENANT_ID", "common")
|
self.msft_tenant_id = APP_CONFIG.get("Service_MSFT_TENANT_ID", "common")
|
||||||
|
|
||||||
# Google OAuth configuration
|
# Google Data-app OAuth
|
||||||
self.google_client_id = APP_CONFIG.get("Service_GOOGLE_CLIENT_ID")
|
self.google_client_id = APP_CONFIG.get("Service_GOOGLE_DATA_CLIENT_ID")
|
||||||
self.google_client_secret = APP_CONFIG.get("Service_GOOGLE_CLIENT_SECRET")
|
self.google_client_secret = APP_CONFIG.get("Service_GOOGLE_DATA_CLIENT_SECRET")
|
||||||
|
|
||||||
def refreshMicrosoftToken(self, refreshToken: str, userId: str, oldToken: Token) -> Optional[Token]:
|
def refreshMicrosoftToken(self, refreshToken: str, userId: str, oldToken: Token) -> Optional[Token]:
|
||||||
"""Refresh Microsoft OAuth token using refresh token"""
|
"""Refresh Microsoft OAuth token using refresh token"""
|
||||||
|
|
@ -49,7 +50,7 @@ class TokenManager:
|
||||||
"client_secret": self.msft_client_secret,
|
"client_secret": self.msft_client_secret,
|
||||||
"grant_type": "refresh_token",
|
"grant_type": "refresh_token",
|
||||||
"refresh_token": refreshToken,
|
"refresh_token": refreshToken,
|
||||||
"scope": "Mail.ReadWrite Mail.Send Mail.ReadWrite.Shared User.Read"
|
"scope": msftDataScopesForRefresh(),
|
||||||
}
|
}
|
||||||
logger.debug(f"refreshMicrosoftToken: Refresh request data prepared (refreshToken length: {len(refreshToken) if refreshToken else 0})")
|
logger.debug(f"refreshMicrosoftToken: Refresh request data prepared (refreshToken length: {len(refreshToken) if refreshToken else 0})")
|
||||||
|
|
||||||
|
|
@ -68,6 +69,7 @@ class TokenManager:
|
||||||
userId=userId,
|
userId=userId,
|
||||||
authority=AuthAuthority.MSFT,
|
authority=AuthAuthority.MSFT,
|
||||||
connectionId=oldToken.connectionId, # Preserve connection ID
|
connectionId=oldToken.connectionId, # Preserve connection ID
|
||||||
|
tokenPurpose=TokenPurpose.DATA_CONNECTION,
|
||||||
tokenAccess=tokenData["access_token"],
|
tokenAccess=tokenData["access_token"],
|
||||||
tokenRefresh=tokenData.get("refresh_token", refreshToken), # Keep old refresh token if new one not provided
|
tokenRefresh=tokenData.get("refresh_token", refreshToken), # Keep old refresh token if new one not provided
|
||||||
tokenType=tokenData.get("token_type", "bearer"),
|
tokenType=tokenData.get("token_type", "bearer"),
|
||||||
|
|
@ -128,6 +130,7 @@ class TokenManager:
|
||||||
userId=userId,
|
userId=userId,
|
||||||
authority=AuthAuthority.GOOGLE,
|
authority=AuthAuthority.GOOGLE,
|
||||||
connectionId=oldToken.connectionId, # Preserve connection ID
|
connectionId=oldToken.connectionId, # Preserve connection ID
|
||||||
|
tokenPurpose=TokenPurpose.DATA_CONNECTION,
|
||||||
tokenAccess=tokenData["access_token"],
|
tokenAccess=tokenData["access_token"],
|
||||||
tokenRefresh=tokenData.get("refresh_token", refreshToken), # Use new refresh token if provided
|
tokenRefresh=tokenData.get("refresh_token", refreshToken), # Use new refresh token if provided
|
||||||
tokenType=tokenData.get("token_type", "bearer"),
|
tokenType=tokenData.get("token_type", "bearer"),
|
||||||
|
|
@ -165,6 +168,15 @@ class TokenManager:
|
||||||
logger.debug(f"refreshToken: Starting refresh for token {oldToken.id}, authority: {oldToken.authority}")
|
logger.debug(f"refreshToken: Starting refresh for token {oldToken.id}, authority: {oldToken.authority}")
|
||||||
logger.debug(f"refreshToken: Token details: userId={oldToken.userId}, connectionId={oldToken.connectionId}, hasRefreshToken={bool(oldToken.tokenRefresh)}")
|
logger.debug(f"refreshToken: Token details: userId={oldToken.userId}, connectionId={oldToken.connectionId}, hasRefreshToken={bool(oldToken.tokenRefresh)}")
|
||||||
|
|
||||||
|
_tp = (
|
||||||
|
oldToken.tokenPurpose.value
|
||||||
|
if isinstance(oldToken.tokenPurpose, TokenPurpose)
|
||||||
|
else oldToken.tokenPurpose
|
||||||
|
)
|
||||||
|
if _tp != TokenPurpose.DATA_CONNECTION.value:
|
||||||
|
logger.warning("refreshToken: skipped — token is not dataConnection")
|
||||||
|
return None
|
||||||
|
|
||||||
# Cooldown: avoid refreshing too frequently if a workflow triggers refresh repeatedly
|
# Cooldown: avoid refreshing too frequently if a workflow triggers refresh repeatedly
|
||||||
# Only allow a new refresh if at least 10 minutes passed since the token was created/refreshed
|
# Only allow a new refresh if at least 10 minutes passed since the token was created/refreshed
|
||||||
try:
|
try:
|
||||||
|
|
@ -266,6 +278,16 @@ class TokenManager:
|
||||||
token = interface.getConnectionToken(connectionId)
|
token = interface.getConnectionToken(connectionId)
|
||||||
if not token:
|
if not token:
|
||||||
return None
|
return None
|
||||||
|
_tp = (
|
||||||
|
token.tokenPurpose.value
|
||||||
|
if isinstance(token.tokenPurpose, TokenPurpose)
|
||||||
|
else token.tokenPurpose
|
||||||
|
)
|
||||||
|
if _tp != TokenPurpose.DATA_CONNECTION.value:
|
||||||
|
logger.warning(
|
||||||
|
f"getFreshToken: connection {connectionId} tokenPurpose is {_tp}, expected dataConnection"
|
||||||
|
)
|
||||||
|
return None
|
||||||
return self.ensureFreshToken(
|
return self.ensureFreshToken(
|
||||||
token,
|
token,
|
||||||
secondsBeforeExpiry=secondsBeforeExpiry,
|
secondsBeforeExpiry=secondsBeforeExpiry,
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,13 @@ import uuid
|
||||||
|
|
||||||
|
|
||||||
class BillingModelEnum(str, Enum):
|
class BillingModelEnum(str, Enum):
|
||||||
"""Billing model types."""
|
"""Billing model types (prepaid only; legacy UNLIMITED in DB maps to PREPAY_MANDATE)."""
|
||||||
PREPAY_MANDATE = "PREPAY_MANDATE" # Prepaid budget shared by all users in mandate
|
PREPAY_MANDATE = "PREPAY_MANDATE" # Prepaid budget shared by all users in mandate
|
||||||
PREPAY_USER = "PREPAY_USER" # Prepaid budget per user within mandate
|
PREPAY_USER = "PREPAY_USER" # Prepaid budget per user within mandate
|
||||||
CREDIT_POSTPAY = "CREDIT_POSTPAY" # Credit with monthly invoice (requires billing address)
|
|
||||||
UNLIMITED = "UNLIMITED" # No cost limitation (internal mandates only)
|
|
||||||
|
# Nur fuer initRootMandateBilling (Root-Mandant PREPAY_USER + Startguthaben in Settings).
|
||||||
|
DEFAULT_USER_CREDIT_CHF = 5.0
|
||||||
|
|
||||||
|
|
||||||
class AccountTypeEnum(str, Enum):
|
class AccountTypeEnum(str, Enum):
|
||||||
|
|
@ -46,30 +48,6 @@ class PeriodTypeEnum(str, Enum):
|
||||||
YEAR = "YEAR"
|
YEAR = "YEAR"
|
||||||
|
|
||||||
|
|
||||||
class BillingAddress(BaseModel):
|
|
||||||
"""Billing address for CREDIT_POSTPAY mandates."""
|
|
||||||
company: str = Field(..., description="Company name")
|
|
||||||
street: str = Field(..., description="Street and number")
|
|
||||||
zip: str = Field(..., description="Postal code")
|
|
||||||
city: str = Field(..., description="City")
|
|
||||||
country: str = Field(default="CH", description="Country code")
|
|
||||||
vatNumber: Optional[str] = Field(None, description="VAT number (optional)")
|
|
||||||
|
|
||||||
|
|
||||||
registerModelLabels(
|
|
||||||
"BillingAddress",
|
|
||||||
{"en": "Billing Address", "de": "Rechnungsadresse"},
|
|
||||||
{
|
|
||||||
"company": {"en": "Company", "de": "Firma"},
|
|
||||||
"street": {"en": "Street", "de": "Strasse"},
|
|
||||||
"zip": {"en": "ZIP", "de": "PLZ"},
|
|
||||||
"city": {"en": "City", "de": "Ort"},
|
|
||||||
"country": {"en": "Country", "de": "Land"},
|
|
||||||
"vatNumber": {"en": "VAT Number", "de": "MwSt-Nummer"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class BillingAccount(BaseModel):
|
class BillingAccount(BaseModel):
|
||||||
"""Billing account for mandate or user-mandate combination."""
|
"""Billing account for mandate or user-mandate combination."""
|
||||||
id: str = Field(
|
id: str = Field(
|
||||||
|
|
@ -79,7 +57,6 @@ class BillingAccount(BaseModel):
|
||||||
userId: Optional[str] = Field(None, description="Foreign key to User (only for PREPAY_USER)")
|
userId: Optional[str] = Field(None, description="Foreign key to User (only for PREPAY_USER)")
|
||||||
accountType: AccountTypeEnum = Field(..., description="Account type: MANDATE or USER")
|
accountType: AccountTypeEnum = Field(..., description="Account type: MANDATE or USER")
|
||||||
balance: float = Field(default=0.0, description="Current balance in CHF")
|
balance: float = Field(default=0.0, description="Current balance in CHF")
|
||||||
creditLimit: Optional[float] = Field(None, description="Credit limit in CHF (only for CREDIT_POSTPAY)")
|
|
||||||
warningThreshold: float = Field(default=0.0, description="Warning threshold in CHF")
|
warningThreshold: float = Field(default=0.0, description="Warning threshold in CHF")
|
||||||
lastWarningAt: Optional[datetime] = Field(None, description="Last warning sent timestamp")
|
lastWarningAt: Optional[datetime] = Field(None, description="Last warning sent timestamp")
|
||||||
enabled: bool = Field(default=True, description="Account is active")
|
enabled: bool = Field(default=True, description="Account is active")
|
||||||
|
|
@ -94,7 +71,6 @@ registerModelLabels(
|
||||||
"userId": {"en": "User ID", "de": "Benutzer-ID"},
|
"userId": {"en": "User ID", "de": "Benutzer-ID"},
|
||||||
"accountType": {"en": "Account Type", "de": "Kontotyp"},
|
"accountType": {"en": "Account Type", "de": "Kontotyp"},
|
||||||
"balance": {"en": "Balance (CHF)", "de": "Guthaben (CHF)"},
|
"balance": {"en": "Balance (CHF)", "de": "Guthaben (CHF)"},
|
||||||
"creditLimit": {"en": "Credit Limit (CHF)", "de": "Kreditlimit (CHF)"},
|
|
||||||
"warningThreshold": {"en": "Warning Threshold (CHF)", "de": "Warnschwelle (CHF)"},
|
"warningThreshold": {"en": "Warning Threshold (CHF)", "de": "Warnschwelle (CHF)"},
|
||||||
"lastWarningAt": {"en": "Last Warning", "de": "Letzte Warnung"},
|
"lastWarningAt": {"en": "Last Warning", "de": "Letzte Warnung"},
|
||||||
"enabled": {"en": "Enabled", "de": "Aktiv"},
|
"enabled": {"en": "Enabled", "de": "Aktiv"},
|
||||||
|
|
@ -161,15 +137,17 @@ class BillingSettings(BaseModel):
|
||||||
billingModel: BillingModelEnum = Field(..., description="Billing model")
|
billingModel: BillingModelEnum = Field(..., description="Billing model")
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
defaultUserCredit: float = Field(default=10.0, description="Initial credit in CHF for new users (PREPAY_USER)")
|
defaultUserCredit: float = Field(
|
||||||
|
default=0.0,
|
||||||
|
description="Automatic initial credit (CHF) for PREPAY_USER only when a user is newly added to the root mandate; other mandates use 0 on join.",
|
||||||
|
)
|
||||||
warningThresholdPercent: float = Field(default=10.0, description="Warning threshold as percentage")
|
warningThresholdPercent: float = Field(default=10.0, description="Warning threshold as percentage")
|
||||||
blockOnZeroBalance: bool = Field(default=True, description="Block AI features when balance is zero")
|
|
||||||
|
|
||||||
# Billing address (required for CREDIT_POSTPAY)
|
# Notifications (e.g. mandate owner / finance — also used when PREPAY_MANDATE pool is exhausted)
|
||||||
billingAddress: Optional[BillingAddress] = Field(None, description="Billing address")
|
notifyEmails: List[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
# Notifications
|
description="Email addresses for billing alerts (mandate pool exhausted, warnings, etc.)",
|
||||||
notifyEmails: List[str] = Field(default_factory=list, description="Email addresses for billing notifications")
|
)
|
||||||
notifyOnWarning: bool = Field(default=True, description="Send email when warning threshold is reached")
|
notifyOnWarning: bool = Field(default=True, description="Send email when warning threshold is reached")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -180,11 +158,15 @@ registerModelLabels(
|
||||||
"id": {"en": "ID", "de": "ID"},
|
"id": {"en": "ID", "de": "ID"},
|
||||||
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID"},
|
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID"},
|
||||||
"billingModel": {"en": "Billing Model", "de": "Abrechnungsmodell"},
|
"billingModel": {"en": "Billing Model", "de": "Abrechnungsmodell"},
|
||||||
"defaultUserCredit": {"en": "Default User Credit (CHF)", "de": "Standard-Startguthaben (CHF)"},
|
"defaultUserCredit": {
|
||||||
|
"en": "Root start credit (CHF)",
|
||||||
|
"de": "Startguthaben nur Root-Mandant (CHF)",
|
||||||
|
},
|
||||||
"warningThresholdPercent": {"en": "Warning Threshold (%)", "de": "Warnschwelle (%)"},
|
"warningThresholdPercent": {"en": "Warning Threshold (%)", "de": "Warnschwelle (%)"},
|
||||||
"blockOnZeroBalance": {"en": "Block on Zero Balance", "de": "Bei 0 blockieren"},
|
"notifyEmails": {
|
||||||
"billingAddress": {"en": "Billing Address", "de": "Rechnungsadresse"},
|
"en": "Billing notification emails (owner / admin)",
|
||||||
"notifyEmails": {"en": "Notification Emails", "de": "Benachrichtigungs-Emails"},
|
"de": "E-Mails für Billing-Alerts (Inhaber/Admin)",
|
||||||
|
},
|
||||||
"notifyOnWarning": {"en": "Notify on Warning", "de": "Bei Warnung benachrichtigen"},
|
"notifyOnWarning": {"en": "Notify on Warning", "de": "Bei Warnung benachrichtigen"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -257,7 +239,6 @@ class BillingBalanceResponse(BaseModel):
|
||||||
currency: str = "CHF"
|
currency: str = "CHF"
|
||||||
warningThreshold: float
|
warningThreshold: float
|
||||||
isWarning: bool
|
isWarning: bool
|
||||||
creditLimit: Optional[float] = None
|
|
||||||
|
|
||||||
|
|
||||||
class BillingStatisticsChartData(BaseModel):
|
class BillingStatisticsChartData(BaseModel):
|
||||||
|
|
@ -285,3 +266,16 @@ class BillingCheckResult(BaseModel):
|
||||||
currentBalance: Optional[float] = None
|
currentBalance: Optional[float] = None
|
||||||
requiredAmount: Optional[float] = None
|
requiredAmount: Optional[float] = None
|
||||||
billingModel: Optional[BillingModelEnum] = None
|
billingModel: Optional[BillingModelEnum] = None
|
||||||
|
|
||||||
|
|
||||||
|
def parseBillingModelFromStoredValue(raw: Optional[str]) -> BillingModelEnum:
|
||||||
|
"""Map DB string to enum. Legacy UNLIMITED / unknown values become PREPAY_MANDATE."""
|
||||||
|
if raw is None or (isinstance(raw, str) and raw.strip() == ""):
|
||||||
|
return BillingModelEnum.PREPAY_MANDATE
|
||||||
|
s = str(raw).strip().upper()
|
||||||
|
if s == "UNLIMITED":
|
||||||
|
return BillingModelEnum.PREPAY_MANDATE
|
||||||
|
try:
|
||||||
|
return BillingModelEnum(raw)
|
||||||
|
except ValueError:
|
||||||
|
return BillingModelEnum.PREPAY_MANDATE
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ Multi-Tenant Design:
|
||||||
- Mandant-Kontext wird per Request-Header (X-Mandate-Id) bestimmt
|
- Mandant-Kontext wird per Request-Header (X-Mandate-Id) bestimmt
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional, Any
|
||||||
from pydantic import BaseModel, Field, ConfigDict
|
from pydantic import BaseModel, Field, ConfigDict, model_validator
|
||||||
from modules.shared.attributeUtils import registerModelLabels
|
from modules.shared.attributeUtils import registerModelLabels
|
||||||
from modules.shared.timeUtils import getUtcTimestamp
|
from modules.shared.timeUtils import getUtcTimestamp
|
||||||
from .datamodelUam import AuthAuthority
|
from .datamodelUam import AuthAuthority
|
||||||
|
|
@ -23,6 +23,13 @@ class TokenStatus(str, Enum):
|
||||||
REVOKED = "revoked"
|
REVOKED = "revoked"
|
||||||
|
|
||||||
|
|
||||||
|
class TokenPurpose(str, Enum):
|
||||||
|
"""Login/session token vs provider token bound to a UserConnection."""
|
||||||
|
|
||||||
|
AUTH_SESSION = "authSession"
|
||||||
|
DATA_CONNECTION = "dataConnection"
|
||||||
|
|
||||||
|
|
||||||
class Token(BaseModel):
|
class Token(BaseModel):
|
||||||
"""
|
"""
|
||||||
Authentication Token model.
|
Authentication Token model.
|
||||||
|
|
@ -38,6 +45,10 @@ class Token(BaseModel):
|
||||||
connectionId: Optional[str] = Field(
|
connectionId: Optional[str] = Field(
|
||||||
None, description="ID of the connection this token belongs to"
|
None, description="ID of the connection this token belongs to"
|
||||||
)
|
)
|
||||||
|
tokenPurpose: Optional[TokenPurpose] = Field(
|
||||||
|
default=None,
|
||||||
|
description="authSession = gateway login JWT; dataConnection = provider OAuth for a connection",
|
||||||
|
)
|
||||||
tokenAccess: str
|
tokenAccess: str
|
||||||
tokenType: str = "bearer"
|
tokenType: str = "bearer"
|
||||||
expiresAt: float = Field(
|
expiresAt: float = Field(
|
||||||
|
|
@ -65,6 +76,22 @@ class Token(BaseModel):
|
||||||
|
|
||||||
model_config = ConfigDict(use_enum_values=True)
|
model_config = ConfigDict(use_enum_values=True)
|
||||||
|
|
||||||
|
@model_validator(mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _defaultTokenPurposeFromDb(cls, data: Any) -> Any:
|
||||||
|
"""Missing tokenPurpose: connection rows → dataConnection; session rows → authSession."""
|
||||||
|
if isinstance(data, dict):
|
||||||
|
tp = data.get("tokenPurpose")
|
||||||
|
if tp is None or tp == "":
|
||||||
|
cid = data.get("connectionId")
|
||||||
|
purpose = (
|
||||||
|
TokenPurpose.DATA_CONNECTION.value
|
||||||
|
if cid
|
||||||
|
else TokenPurpose.AUTH_SESSION.value
|
||||||
|
)
|
||||||
|
data = {**data, "tokenPurpose": purpose}
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
registerModelLabels(
|
registerModelLabels(
|
||||||
"Token",
|
"Token",
|
||||||
|
|
@ -74,6 +101,7 @@ registerModelLabels(
|
||||||
"userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
|
"userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
|
||||||
"authority": {"en": "Authority", "de": "Autorität", "fr": "Autorité"},
|
"authority": {"en": "Authority", "de": "Autorität", "fr": "Autorité"},
|
||||||
"connectionId": {"en": "Connection ID", "de": "Verbindungs-ID", "fr": "ID de connexion"},
|
"connectionId": {"en": "Connection ID", "de": "Verbindungs-ID", "fr": "ID de connexion"},
|
||||||
|
"tokenPurpose": {"en": "Token purpose", "de": "Token-Verwendung", "fr": "Usage du jeton"},
|
||||||
"tokenAccess": {"en": "Access Token", "de": "Zugriffstoken", "fr": "Jeton d'accès"},
|
"tokenAccess": {"en": "Access Token", "de": "Zugriffstoken", "fr": "Jeton d'accès"},
|
||||||
"tokenType": {"en": "Token Type", "de": "Token-Typ", "fr": "Type de jeton"},
|
"tokenType": {"en": "Token Type", "de": "Token-Typ", "fr": "Type de jeton"},
|
||||||
"expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
|
"expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
|
||||||
|
|
|
||||||
|
|
@ -1221,10 +1221,28 @@ def _preflight_billing_check(services, mandateId: str, featureInstanceId: Option
|
||||||
try:
|
try:
|
||||||
balanceCheck = billingService.checkBalance(0.01)
|
balanceCheck = billingService.checkBalance(0.01)
|
||||||
if not balanceCheck.allowed:
|
if not balanceCheck.allowed:
|
||||||
raise BillingService.InsufficientBalanceException(
|
mid = str(getattr(services, "mandateId", None) or mandateId or "")
|
||||||
currentBalance=balanceCheck.currentBalance or 0.0,
|
from modules.datamodels.datamodelBilling import BillingModelEnum
|
||||||
requiredAmount=0.01,
|
from modules.serviceCenter.services.serviceBilling.billingExhaustedNotify import (
|
||||||
message=f"Ungenuegendes Guthaben. Aktuell: CHF {balanceCheck.currentBalance:.2f}"
|
maybeEmailMandatePoolExhausted,
|
||||||
|
)
|
||||||
|
if balanceCheck.billingModel == BillingModelEnum.PREPAY_MANDATE:
|
||||||
|
u = getattr(services, "user", None)
|
||||||
|
ulabel = (
|
||||||
|
(getattr(u, "email", None) or getattr(u, "username", None) or str(getattr(u, "id", "")))
|
||||||
|
if u is not None else ""
|
||||||
|
)
|
||||||
|
maybeEmailMandatePoolExhausted(
|
||||||
|
mid,
|
||||||
|
str(getattr(u, "id", "") if u is not None else ""),
|
||||||
|
ulabel,
|
||||||
|
float(balanceCheck.currentBalance or 0.0),
|
||||||
|
0.01,
|
||||||
|
)
|
||||||
|
raise BillingService.InsufficientBalanceException.fromBalanceCheck(
|
||||||
|
balanceCheck,
|
||||||
|
mid,
|
||||||
|
0.01,
|
||||||
)
|
)
|
||||||
rbacAllowedProviders = billingService.getallowedProviders()
|
rbacAllowedProviders = billingService.getallowedProviders()
|
||||||
if not rbacAllowedProviders:
|
if not rbacAllowedProviders:
|
||||||
|
|
|
||||||
|
|
@ -110,14 +110,15 @@ TEMPLATE_ROLES = [
|
||||||
{
|
{
|
||||||
"roleLabel": "workspace-admin",
|
"roleLabel": "workspace-admin",
|
||||||
"description": {
|
"description": {
|
||||||
"en": "Workspace Admin - Full access to AI workspace",
|
"en": "Workspace Admin - All UI and API actions; data is always scoped to own records (same privacy as users)",
|
||||||
"de": "Workspace Admin - Vollzugriff auf AI Workspace",
|
"de": "Workspace Admin - Alle UI- und API-Aktionen; Daten immer nur eigene Datensätze (gleiche Privatsphäre wie User)",
|
||||||
"fr": "Administrateur Workspace - Acces complet au workspace AI"
|
"fr": "Administrateur Workspace - Toute l'UI et les API; donnees limitees a ses propres enregistrements"
|
||||||
},
|
},
|
||||||
"accessRules": [
|
"accessRules": [
|
||||||
{"context": "UI", "item": None, "view": True},
|
{"context": "UI", "item": None, "view": True},
|
||||||
{"context": "RESOURCE", "item": None, "view": True},
|
{"context": "RESOURCE", "item": None, "view": True},
|
||||||
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"},
|
# DATA: never ALL in shared instances — every role (including admin) sees only _createdBy = self
|
||||||
|
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ from fastapi.responses import StreamingResponse, JSONResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from modules.auth import limiter, getRequestContext, RequestContext
|
from modules.auth import limiter, getRequestContext, RequestContext
|
||||||
|
from modules.serviceCenter.services.serviceBilling.mainServiceBilling import (
|
||||||
|
InsufficientBalanceException,
|
||||||
|
)
|
||||||
from modules.interfaces import interfaceDbChat, interfaceDbManagement
|
from modules.interfaces import interfaceDbChat, interfaceDbManagement
|
||||||
from modules.interfaces.interfaceAiObjects import AiObjects
|
from modules.interfaces.interfaceAiObjects import AiObjects
|
||||||
from modules.serviceCenter.core.serviceStreaming import get_event_manager
|
from modules.serviceCenter.core.serviceStreaming import get_event_manager
|
||||||
|
|
@ -581,12 +584,21 @@ async def _runWorkspaceAgent(
|
||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Workspace agent error: {e}", exc_info=True)
|
if isinstance(e, InsufficientBalanceException):
|
||||||
await eventManager.emit_event(queueId, "error", {
|
logger.warning(f"Workspace blocked by billing: {e.message}")
|
||||||
"type": "error",
|
await eventManager.emit_event(queueId, "error", {
|
||||||
"content": str(e),
|
"type": "error",
|
||||||
"workflowId": workflowId,
|
"content": e.message,
|
||||||
})
|
"workflowId": workflowId,
|
||||||
|
"item": e.toClientDict(),
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
logger.error(f"Workspace agent error: {e}", exc_info=True)
|
||||||
|
await eventManager.emit_event(queueId, "error", {
|
||||||
|
"type": "error",
|
||||||
|
"content": str(e),
|
||||||
|
"workflowId": workflowId,
|
||||||
|
})
|
||||||
finally:
|
finally:
|
||||||
eventManager._unregister_agent_task(queueId)
|
eventManager._unregister_agent_task(queueId)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1962,6 +1962,8 @@ def _createStoreResourceRules(db: DatabaseConnector) -> None:
|
||||||
storeResources = [
|
storeResources = [
|
||||||
"resource.store.automation",
|
"resource.store.automation",
|
||||||
"resource.store.teamsbot",
|
"resource.store.teamsbot",
|
||||||
|
"resource.store.workspace",
|
||||||
|
"resource.store.commcoach",
|
||||||
]
|
]
|
||||||
|
|
||||||
storeRules = []
|
storeRules = []
|
||||||
|
|
@ -1998,7 +2000,7 @@ def _createStoreResourceRules(db: DatabaseConnector) -> None:
|
||||||
def initRootMandateBilling(mandateId: str) -> None:
|
def initRootMandateBilling(mandateId: str) -> None:
|
||||||
"""
|
"""
|
||||||
Initialize billing settings for root mandate.
|
Initialize billing settings for root mandate.
|
||||||
Root mandate uses PREPAY_USER model with 10 CHF initial credit per user.
|
Root mandate uses PREPAY_USER model with default initial credit per user in settings (DEFAULT_USER_CREDIT_CHF at bootstrap only).
|
||||||
Creates billing accounts for ALL users regardless of billing model (for audit trail).
|
Creates billing accounts for ALL users regardless of billing model (for audit trail).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -2007,7 +2009,12 @@ def initRootMandateBilling(mandateId: str) -> None:
|
||||||
try:
|
try:
|
||||||
from modules.interfaces.interfaceDbBilling import _getRootInterface
|
from modules.interfaces.interfaceDbBilling import _getRootInterface
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface as getAppRootInterface
|
from modules.interfaces.interfaceDbApp import getRootInterface as getAppRootInterface
|
||||||
from modules.datamodels.datamodelBilling import BillingSettings, BillingModelEnum
|
from modules.datamodels.datamodelBilling import (
|
||||||
|
BillingSettings,
|
||||||
|
BillingModelEnum,
|
||||||
|
DEFAULT_USER_CREDIT_CHF,
|
||||||
|
parseBillingModelFromStoredValue,
|
||||||
|
)
|
||||||
|
|
||||||
billingInterface = _getRootInterface()
|
billingInterface = _getRootInterface()
|
||||||
appInterface = getAppRootInterface()
|
appInterface = getAppRootInterface()
|
||||||
|
|
@ -2020,27 +2027,28 @@ def initRootMandateBilling(mandateId: str) -> None:
|
||||||
settings = BillingSettings(
|
settings = BillingSettings(
|
||||||
mandateId=mandateId,
|
mandateId=mandateId,
|
||||||
billingModel=BillingModelEnum.PREPAY_USER,
|
billingModel=BillingModelEnum.PREPAY_USER,
|
||||||
defaultUserCredit=10.0,
|
defaultUserCredit=DEFAULT_USER_CREDIT_CHF,
|
||||||
warningThresholdPercent=10.0,
|
warningThresholdPercent=10.0,
|
||||||
blockOnZeroBalance=True,
|
|
||||||
notifyOnWarning=True
|
notifyOnWarning=True
|
||||||
)
|
)
|
||||||
|
|
||||||
billingInterface.createSettings(settings)
|
billingInterface.createSettings(settings)
|
||||||
logger.info(f"Created billing settings for root mandate: PREPAY_USER with 10 CHF default credit")
|
logger.info(
|
||||||
|
f"Created billing settings for root mandate: PREPAY_USER with {DEFAULT_USER_CREDIT_CHF} CHF default credit"
|
||||||
|
)
|
||||||
existingSettings = billingInterface.getSettings(mandateId)
|
existingSettings = billingInterface.getSettings(mandateId)
|
||||||
|
|
||||||
# Always create user accounts for all users (audit trail)
|
# Always create user accounts for all users (audit trail)
|
||||||
if existingSettings:
|
if existingSettings:
|
||||||
billingModel = existingSettings.get("billingModel", "UNLIMITED")
|
billingModel = parseBillingModelFromStoredValue(
|
||||||
if billingModel == BillingModelEnum.UNLIMITED.value:
|
existingSettings.get("billingModel")
|
||||||
return # No accounts needed for UNLIMITED
|
).value
|
||||||
|
|
||||||
# Initial balance depends on billing model
|
# Initial balance depends on billing model
|
||||||
if billingModel == BillingModelEnum.PREPAY_USER.value:
|
if billingModel == BillingModelEnum.PREPAY_USER.value:
|
||||||
initialBalance = existingSettings.get("defaultUserCredit", 10.0)
|
initialBalance = float(existingSettings.get("defaultUserCredit", 0.0))
|
||||||
else:
|
else:
|
||||||
initialBalance = 0.0 # PREPAY_MANDATE / CREDIT_POSTPAY: budget on pool
|
initialBalance = 0.0 # PREPAY_MANDATE: budget on pool account
|
||||||
|
|
||||||
userMandates = appInterface.getUserMandatesByMandate(mandateId)
|
userMandates = appInterface.getUserMandatesByMandate(mandateId)
|
||||||
accountsCreated = 0
|
accountsCreated = 0
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ from modules.datamodels.datamodelRbac import (
|
||||||
Role,
|
Role,
|
||||||
)
|
)
|
||||||
from modules.datamodels.datamodelUam import AccessLevel
|
from modules.datamodels.datamodelUam import AccessLevel
|
||||||
from modules.datamodels.datamodelSecurity import Token, AuthEvent, TokenStatus
|
from modules.datamodels.datamodelSecurity import Token, AuthEvent, TokenStatus, TokenPurpose
|
||||||
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResult
|
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResult
|
||||||
from modules.datamodels.datamodelMembership import (
|
from modules.datamodels.datamodelMembership import (
|
||||||
UserMandate,
|
UserMandate,
|
||||||
|
|
@ -687,11 +687,17 @@ class AppObjects:
|
||||||
externalUsername: str = None,
|
externalUsername: str = None,
|
||||||
externalEmail: str = None,
|
externalEmail: str = None,
|
||||||
isSysAdmin: bool = False,
|
isSysAdmin: bool = False,
|
||||||
|
addExternalIdentityConnection: bool = True,
|
||||||
) -> User:
|
) -> User:
|
||||||
"""
|
"""
|
||||||
Create a new user.
|
Create a new user.
|
||||||
|
|
||||||
Note: Role assignment is done via createUserMandate(), not via User fields.
|
Note: Role assignment is done via createUserMandate(), not via User fields.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
addExternalIdentityConnection: If True (default) and externalId/externalUsername are set,
|
||||||
|
creates a UserConnection row. OAuth login-only flows should pass False (data connection
|
||||||
|
is created separately via /auth/connect).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Ensure username is a string
|
# Ensure username is a string
|
||||||
|
|
@ -727,8 +733,9 @@ class AppObjects:
|
||||||
if not createdRecord or not createdRecord.get("id"):
|
if not createdRecord or not createdRecord.get("id"):
|
||||||
raise ValueError("Failed to create user record")
|
raise ValueError("Failed to create user record")
|
||||||
|
|
||||||
# Add external connection if provided
|
# Optional: mirror external IdP identity into UserConnections (data/API OAuth).
|
||||||
if externalId and externalUsername:
|
# Auth-only login (Google/MSFT JWT) must NOT create a connection — see OAuth split.
|
||||||
|
if addExternalIdentityConnection and externalId and externalUsername:
|
||||||
self.addUserConnection(
|
self.addUserConnection(
|
||||||
createdRecord["id"],
|
createdRecord["id"],
|
||||||
authenticationAuthority,
|
authenticationAuthority,
|
||||||
|
|
@ -746,7 +753,7 @@ class AppObjects:
|
||||||
|
|
||||||
# Clear cache to ensure fresh data (already done above)
|
# Clear cache to ensure fresh data (already done above)
|
||||||
|
|
||||||
# Assign new user to the root mandate with system 'viewer' role
|
# Assign new user to the root mandate with mandate-instance 'user' role (no feature instances)
|
||||||
userId = createdUser[0]["id"]
|
userId = createdUser[0]["id"]
|
||||||
self._assignUserToRootMandate(userId)
|
self._assignUserToRootMandate(userId)
|
||||||
|
|
||||||
|
|
@ -815,7 +822,7 @@ class AppObjects:
|
||||||
|
|
||||||
def _assignUserToRootMandate(self, userId: str) -> None:
|
def _assignUserToRootMandate(self, userId: str) -> None:
|
||||||
"""
|
"""
|
||||||
Assign a new user to the root mandate with the mandate-instance 'viewer' role.
|
Assign a new user to the root mandate with the mandate-instance 'user' role.
|
||||||
This ensures every user has a base membership in the system mandate.
|
This ensures every user has a base membership in the system mandate.
|
||||||
|
|
||||||
Uses the mandate-instance role (mandateId=rootMandateId), not the global template.
|
Uses the mandate-instance role (mandateId=rootMandateId), not the global template.
|
||||||
|
|
@ -839,17 +846,17 @@ class AppObjects:
|
||||||
logger.debug(f"User {userId} already assigned to root mandate")
|
logger.debug(f"User {userId} already assigned to root mandate")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Find the mandate-instance 'viewer' role (bound to this mandate, not a global template)
|
# Mandate-instance 'user' role (bound to this mandate, not a global template)
|
||||||
mandateViewerRoles = self.db.getRecordset(
|
mandateUserRoles = self.db.getRecordset(
|
||||||
Role,
|
Role,
|
||||||
recordFilter={"roleLabel": "viewer", "mandateId": rootMandateId, "featureInstanceId": None}
|
recordFilter={"roleLabel": "user", "mandateId": rootMandateId, "featureInstanceId": None}
|
||||||
)
|
)
|
||||||
viewerRoleId = mandateViewerRoles[0].get("id") if mandateViewerRoles else None
|
userRoleId = mandateUserRoles[0].get("id") if mandateUserRoles else None
|
||||||
|
|
||||||
roleIds = [viewerRoleId] if viewerRoleId else []
|
roleIds = [userRoleId] if userRoleId else []
|
||||||
|
|
||||||
self.createUserMandate(userId, rootMandateId, roleIds)
|
self.createUserMandate(userId, rootMandateId, roleIds)
|
||||||
logger.info(f"Assigned user {userId} to root mandate with viewer role")
|
logger.info(f"Assigned user {userId} to root mandate with user role")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Log but don't fail user creation
|
# Log but don't fail user creation
|
||||||
|
|
@ -1641,8 +1648,9 @@ class AppObjects:
|
||||||
Ensure a user has a billing account for the mandate if billing is configured.
|
Ensure a user has a billing account for the mandate if billing is configured.
|
||||||
User accounts are always created for all billing models (for audit trail).
|
User accounts are always created for all billing models (for audit trail).
|
||||||
Initial balance depends on billing model:
|
Initial balance depends on billing model:
|
||||||
- PREPAY_USER: defaultUserCredit from settings
|
- PREPAY_USER: defaultUserCredit from mandate BillingSettings when joining the root mandate (missing key => 0.0);
|
||||||
- PREPAY_MANDATE / CREDIT_POSTPAY: 0.0 (budget is on mandate pool)
|
other mandates get 0.0.
|
||||||
|
- PREPAY_MANDATE: 0.0 on the user account (shared pool — no per-user start credit)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
userId: User ID
|
userId: User ID
|
||||||
|
|
@ -1650,7 +1658,7 @@ class AppObjects:
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from modules.interfaces.interfaceDbBilling import _getRootInterface as getBillingRootInterface
|
from modules.interfaces.interfaceDbBilling import _getRootInterface as getBillingRootInterface
|
||||||
from modules.datamodels.datamodelBilling import BillingModelEnum
|
from modules.datamodels.datamodelBilling import BillingModelEnum, parseBillingModelFromStoredValue
|
||||||
|
|
||||||
billingInterface = getBillingRootInterface()
|
billingInterface = getBillingRootInterface()
|
||||||
settings = billingInterface.getSettings(mandateId)
|
settings = billingInterface.getSettings(mandateId)
|
||||||
|
|
@ -1658,18 +1666,22 @@ class AppObjects:
|
||||||
if not settings:
|
if not settings:
|
||||||
return # No billing configured for this mandate
|
return # No billing configured for this mandate
|
||||||
|
|
||||||
billingModel = settings.get("billingModel", "UNLIMITED")
|
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
|
||||||
if billingModel == BillingModelEnum.UNLIMITED.value:
|
|
||||||
return # No accounts needed for UNLIMITED
|
|
||||||
|
|
||||||
# Initial balance depends on billing model
|
# Initial balance depends on billing model (start credit only on root mandate for PREPAY_USER)
|
||||||
if billingModel == BillingModelEnum.PREPAY_USER.value:
|
rootMandateId = self._getRootMandateId()
|
||||||
initialBalance = settings.get("defaultUserCredit", 10.0)
|
isRootMandate = rootMandateId is not None and str(mandateId) == str(rootMandateId)
|
||||||
|
if billingModel == BillingModelEnum.PREPAY_USER:
|
||||||
|
initialBalance = (
|
||||||
|
float(settings.get("defaultUserCredit", 0.0))
|
||||||
|
if isRootMandate
|
||||||
|
else 0.0
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
initialBalance = 0.0 # PREPAY_MANDATE / CREDIT_POSTPAY: budget is on pool
|
initialBalance = 0.0 # PREPAY_MANDATE: budget is on pool
|
||||||
|
|
||||||
billingInterface.getOrCreateUserAccount(mandateId, userId, initialBalance=initialBalance)
|
billingInterface.getOrCreateUserAccount(mandateId, userId, initialBalance=initialBalance)
|
||||||
logger.info(f"Ensured billing account for user {userId} in mandate {mandateId} (model={billingModel}, initial={initialBalance} CHF)")
|
logger.info(f"Ensured billing account for user {userId} in mandate {mandateId} (model={billingModel.value}, initial={initialBalance} CHF)")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to create billing account for user {userId} (non-critical): {e}")
|
logger.warning(f"Failed to create billing account for user {userId} (non-critical): {e}")
|
||||||
|
|
@ -1678,6 +1690,8 @@ class AppObjects:
|
||||||
"""
|
"""
|
||||||
Delete a UserMandate record (remove user from mandate).
|
Delete a UserMandate record (remove user from mandate).
|
||||||
CASCADE will delete UserMandateRole entries.
|
CASCADE will delete UserMandateRole entries.
|
||||||
|
Also removes FeatureAccess rows for any feature instances that belong to this mandate
|
||||||
|
(FeatureAccessRole rows cascade from FeatureAccess).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
userId: User ID
|
userId: User ID
|
||||||
|
|
@ -1691,6 +1705,24 @@ class AppObjects:
|
||||||
if not existing:
|
if not existing:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# Drop feature-instance memberships for instances under this mandate
|
||||||
|
instanceRows = self.db.getRecordset(
|
||||||
|
FeatureInstance,
|
||||||
|
recordFilter={"mandateId": mandateId}
|
||||||
|
)
|
||||||
|
for row in instanceRows:
|
||||||
|
instId = row.get("id")
|
||||||
|
if not instId:
|
||||||
|
continue
|
||||||
|
accessRows = self.db.getRecordset(
|
||||||
|
FeatureAccess,
|
||||||
|
recordFilter={"userId": userId, "featureInstanceId": instId}
|
||||||
|
)
|
||||||
|
for acc in accessRows:
|
||||||
|
accId = acc.get("id")
|
||||||
|
if accId:
|
||||||
|
self.db.recordDelete(FeatureAccess, accId)
|
||||||
|
|
||||||
return self.db.recordDelete(UserMandate, existing.id)
|
return self.db.recordDelete(UserMandate, existing.id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error deleting UserMandate: {e}")
|
logger.error(f"Error deleting UserMandate: {e}")
|
||||||
|
|
@ -2544,6 +2576,16 @@ class AppObjects:
|
||||||
"Access tokens cannot have connectionId - use saveConnectionToken instead"
|
"Access tokens cannot have connectionId - use saveConnectionToken instead"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_tp = (
|
||||||
|
token.tokenPurpose.value
|
||||||
|
if isinstance(token.tokenPurpose, TokenPurpose)
|
||||||
|
else token.tokenPurpose
|
||||||
|
)
|
||||||
|
if _tp != TokenPurpose.AUTH_SESSION.value:
|
||||||
|
raise ValueError(
|
||||||
|
"saveAccessToken requires tokenPurpose=authSession (gateway session JWT)"
|
||||||
|
)
|
||||||
|
|
||||||
# Validate user context
|
# Validate user context
|
||||||
if not self.currentUser or not self.currentUser.id:
|
if not self.currentUser or not self.currentUser.id:
|
||||||
raise ValueError("No valid user context available for token storage")
|
raise ValueError("No valid user context available for token storage")
|
||||||
|
|
@ -2566,6 +2608,7 @@ class AppObjects:
|
||||||
"userId": self.currentUser.id,
|
"userId": self.currentUser.id,
|
||||||
"authority": token.authority,
|
"authority": token.authority,
|
||||||
"connectionId": None, # Ensure we only delete access tokens
|
"connectionId": None, # Ensure we only delete access tokens
|
||||||
|
"tokenPurpose": TokenPurpose.AUTH_SESSION.value,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
deleted_count = 0
|
deleted_count = 0
|
||||||
|
|
@ -2611,6 +2654,16 @@ class AppObjects:
|
||||||
"Connection tokens must have connectionId - use saveAccessToken instead"
|
"Connection tokens must have connectionId - use saveAccessToken instead"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_tp = (
|
||||||
|
token.tokenPurpose.value
|
||||||
|
if isinstance(token.tokenPurpose, TokenPurpose)
|
||||||
|
else token.tokenPurpose
|
||||||
|
)
|
||||||
|
if _tp != TokenPurpose.DATA_CONNECTION.value:
|
||||||
|
raise ValueError(
|
||||||
|
"saveConnectionToken requires tokenPurpose=dataConnection (provider OAuth)"
|
||||||
|
)
|
||||||
|
|
||||||
# Validate user context
|
# Validate user context
|
||||||
if not self.currentUser or not self.currentUser.id:
|
if not self.currentUser or not self.currentUser.id:
|
||||||
raise ValueError("No valid user context available for token storage")
|
raise ValueError("No valid user context available for token storage")
|
||||||
|
|
@ -2748,6 +2801,7 @@ class AppObjects:
|
||||||
authority: AuthAuthority,
|
authority: AuthAuthority,
|
||||||
sessionId: str = None,
|
sessionId: str = None,
|
||||||
mandateId: str = None,
|
mandateId: str = None,
|
||||||
|
tokenPurpose: str = None,
|
||||||
) -> Optional[Token]:
|
) -> Optional[Token]:
|
||||||
"""Find an active access token by its id (jti) with optional session/tenant scoping."""
|
"""Find an active access token by its id (jti) with optional session/tenant scoping."""
|
||||||
try:
|
try:
|
||||||
|
|
@ -2763,6 +2817,8 @@ class AppObjects:
|
||||||
recordFilter["sessionId"] = sessionId
|
recordFilter["sessionId"] = sessionId
|
||||||
if mandateId is not None:
|
if mandateId is not None:
|
||||||
recordFilter["mandateId"] = mandateId
|
recordFilter["mandateId"] = mandateId
|
||||||
|
if tokenPurpose is not None:
|
||||||
|
recordFilter["tokenPurpose"] = tokenPurpose
|
||||||
tokens = self.db.getRecordset(Token, recordFilter=recordFilter)
|
tokens = self.db.getRecordset(Token, recordFilter=recordFilter)
|
||||||
if not tokens:
|
if not tokens:
|
||||||
return None
|
return None
|
||||||
|
|
@ -3405,6 +3461,10 @@ def getInterface(currentUser: User, mandateId: Optional[str] = None) -> AppObjec
|
||||||
instance = AppObjects(currentUser)
|
instance = AppObjects(currentUser)
|
||||||
instance.setUserContext(currentUser, mandateId=effectiveMandateId)
|
instance.setUserContext(currentUser, mandateId=effectiveMandateId)
|
||||||
_gatewayInterfaces[contextKey] = instance
|
_gatewayInterfaces[contextKey] = instance
|
||||||
|
else:
|
||||||
|
# Re-apply user on every resolve: a prior code path (e.g. legacy logout) may have
|
||||||
|
# cleared currentUser on this cached singleton; OAuth/login must not see a stale context.
|
||||||
|
_gatewayInterfaces[contextKey].setUserContext(currentUser, mandateId=effectiveMandateId)
|
||||||
|
|
||||||
return _gatewayInterfaces[contextKey]
|
return _gatewayInterfaces[contextKey]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ from modules.datamodels.datamodelBilling import (
|
||||||
BillingSettings,
|
BillingSettings,
|
||||||
StripeWebhookEvent,
|
StripeWebhookEvent,
|
||||||
UsageStatistics,
|
UsageStatistics,
|
||||||
BillingAddress,
|
|
||||||
BillingModelEnum,
|
BillingModelEnum,
|
||||||
AccountTypeEnum,
|
AccountTypeEnum,
|
||||||
TransactionTypeEnum,
|
TransactionTypeEnum,
|
||||||
|
|
@ -31,10 +30,49 @@ from modules.datamodels.datamodelBilling import (
|
||||||
PeriodTypeEnum,
|
PeriodTypeEnum,
|
||||||
BillingBalanceResponse,
|
BillingBalanceResponse,
|
||||||
BillingCheckResult,
|
BillingCheckResult,
|
||||||
|
parseBillingModelFromStoredValue,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _getAppDatabaseConnector() -> DatabaseConnector:
|
||||||
|
"""App DB connector (same config as UserMandate reads in this module)."""
|
||||||
|
return DatabaseConnector(
|
||||||
|
dbDatabase=APP_CONFIG.get("DB_DATABASE", "poweron_app"),
|
||||||
|
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
|
||||||
|
dbPort=int(APP_CONFIG.get("DB_PORT", "5432")),
|
||||||
|
dbUser=APP_CONFIG.get("DB_USER"),
|
||||||
|
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _getRootMandateIdFromAppDb(appDb: DatabaseConnector) -> Optional[str]:
|
||||||
|
"""Resolve root mandate id (name='root', isSystem=True) from app database."""
|
||||||
|
try:
|
||||||
|
rows = appDb.getRecordset(Mandate, recordFilter={"name": "root", "isSystem": True})
|
||||||
|
if rows:
|
||||||
|
rid = rows[0].get("id")
|
||||||
|
return str(rid) if rid is not None else None
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Could not resolve root mandate id from app DB: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_cachedRootMandateId: Optional[str] = None
|
||||||
|
_rootMandateIdCacheResolved: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def _getCachedRootMandateId() -> Optional[str]:
|
||||||
|
"""Lazy-cached root mandate id (name=root, isSystem=True) for hot paths."""
|
||||||
|
global _cachedRootMandateId, _rootMandateIdCacheResolved
|
||||||
|
if not _rootMandateIdCacheResolved:
|
||||||
|
appDb = _getAppDatabaseConnector()
|
||||||
|
_cachedRootMandateId = _getRootMandateIdFromAppDb(appDb)
|
||||||
|
_rootMandateIdCacheResolved = True
|
||||||
|
return _cachedRootMandateId
|
||||||
|
|
||||||
|
|
||||||
# Singleton factory for BillingObjects instances
|
# Singleton factory for BillingObjects instances
|
||||||
_billingInterfaces: Dict[str, "BillingObjects"] = {}
|
_billingInterfaces: Dict[str, "BillingObjects"] = {}
|
||||||
|
|
||||||
|
|
@ -121,6 +159,8 @@ class BillingObjects:
|
||||||
"""
|
"""
|
||||||
Get billing settings for a mandate.
|
Get billing settings for a mandate.
|
||||||
|
|
||||||
|
Normalizes billingModel for API (legacy UNLIMITED → PREPAY_MANDATE) and persists once.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
mandateId: Mandate ID
|
mandateId: Mandate ID
|
||||||
|
|
||||||
|
|
@ -132,7 +172,29 @@ class BillingObjects:
|
||||||
BillingSettings,
|
BillingSettings,
|
||||||
recordFilter={"mandateId": mandateId}
|
recordFilter={"mandateId": mandateId}
|
||||||
)
|
)
|
||||||
return results[0] if results else None
|
if not results:
|
||||||
|
return None
|
||||||
|
row = dict(results[0])
|
||||||
|
raw_bm = row.get("billingModel")
|
||||||
|
parsed = parseBillingModelFromStoredValue(raw_bm)
|
||||||
|
if str(raw_bm or "").strip().upper() == "UNLIMITED":
|
||||||
|
try:
|
||||||
|
self.updateSettings(
|
||||||
|
row["id"],
|
||||||
|
{"billingModel": BillingModelEnum.PREPAY_MANDATE.value},
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Migrated billing settings for mandate %s: UNLIMITED → PREPAY_MANDATE",
|
||||||
|
mandateId,
|
||||||
|
)
|
||||||
|
except Exception as mig_err:
|
||||||
|
logger.warning(
|
||||||
|
"Could not persist billing model migration for mandate %s: %s",
|
||||||
|
mandateId,
|
||||||
|
mig_err,
|
||||||
|
)
|
||||||
|
row["billingModel"] = parsed.value
|
||||||
|
return row
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting billing settings: {e}")
|
logger.error(f"Error getting billing settings: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
@ -148,11 +210,6 @@ class BillingObjects:
|
||||||
Created settings dict
|
Created settings dict
|
||||||
"""
|
"""
|
||||||
settingsDict = settings.model_dump(exclude_none=True)
|
settingsDict = settings.model_dump(exclude_none=True)
|
||||||
|
|
||||||
# Handle nested BillingAddress
|
|
||||||
if settings.billingAddress:
|
|
||||||
settingsDict["billingAddress"] = settings.billingAddress.model_dump()
|
|
||||||
|
|
||||||
return self.db.recordCreate(BillingSettings, settingsDict)
|
return self.db.recordCreate(BillingSettings, settingsDict)
|
||||||
|
|
||||||
def updateSettings(self, settingsId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
def updateSettings(self, settingsId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
|
|
@ -168,7 +225,7 @@ class BillingObjects:
|
||||||
"""
|
"""
|
||||||
return self.db.recordModify(BillingSettings, settingsId, updates)
|
return self.db.recordModify(BillingSettings, settingsId, updates)
|
||||||
|
|
||||||
def getOrCreateSettings(self, mandateId: str, defaultModel: BillingModelEnum = BillingModelEnum.UNLIMITED) -> Dict[str, Any]:
|
def getOrCreateSettings(self, mandateId: str, defaultModel: BillingModelEnum = BillingModelEnum.PREPAY_MANDATE) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get or create billing settings for a mandate.
|
Get or create billing settings for a mandate.
|
||||||
|
|
||||||
|
|
@ -186,10 +243,9 @@ class BillingObjects:
|
||||||
settings = BillingSettings(
|
settings = BillingSettings(
|
||||||
mandateId=mandateId,
|
mandateId=mandateId,
|
||||||
billingModel=defaultModel,
|
billingModel=defaultModel,
|
||||||
defaultUserCredit=10.0,
|
defaultUserCredit=0.0,
|
||||||
warningThresholdPercent=10.0,
|
warningThresholdPercent=10.0,
|
||||||
blockOnZeroBalance=True,
|
notifyOnWarning=True,
|
||||||
notifyOnWarning=True
|
|
||||||
)
|
)
|
||||||
return self.createSettings(settings)
|
return self.createSettings(settings)
|
||||||
|
|
||||||
|
|
@ -365,7 +421,7 @@ class BillingObjects:
|
||||||
def ensureAllMandateSettingsExist(self) -> int:
|
def ensureAllMandateSettingsExist(self) -> int:
|
||||||
"""
|
"""
|
||||||
Efficiently ensure all mandates have billing settings.
|
Efficiently ensure all mandates have billing settings.
|
||||||
Creates default settings (PREPAY_USER) for mandates without settings.
|
Creates default settings (PREPAY_MANDATE, 0 CHF) for mandates without settings.
|
||||||
Uses bulk queries to minimize database connections.
|
Uses bulk queries to minimize database connections.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|
@ -397,11 +453,10 @@ class BillingObjects:
|
||||||
# Create default billing settings
|
# Create default billing settings
|
||||||
settings = BillingSettings(
|
settings = BillingSettings(
|
||||||
mandateId=mandateId,
|
mandateId=mandateId,
|
||||||
billingModel=BillingModelEnum.PREPAY_USER,
|
billingModel=BillingModelEnum.PREPAY_MANDATE,
|
||||||
defaultUserCredit=10.0,
|
defaultUserCredit=0.0,
|
||||||
warningThresholdPercent=10.0,
|
warningThresholdPercent=10.0,
|
||||||
blockOnZeroBalance=True,
|
notifyOnWarning=True,
|
||||||
notifyOnWarning=True
|
|
||||||
)
|
)
|
||||||
self.createSettings(settings)
|
self.createSettings(settings)
|
||||||
existingMandateIds.add(mandateId) # Track newly created
|
existingMandateIds.add(mandateId) # Track newly created
|
||||||
|
|
@ -421,8 +476,8 @@ class BillingObjects:
|
||||||
Ensure all users across all mandates have billing accounts.
|
Ensure all users across all mandates have billing accounts.
|
||||||
User accounts are always created regardless of billing model (for audit trail).
|
User accounts are always created regardless of billing model (for audit trail).
|
||||||
Initial balance depends on billing model:
|
Initial balance depends on billing model:
|
||||||
- PREPAY_USER: defaultUserCredit from settings
|
- PREPAY_USER: defaultUserCredit from settings only for the root mandate; other mandates get 0.0
|
||||||
- PREPAY_MANDATE / CREDIT_POSTPAY: 0.0 (budget is on pool)
|
- PREPAY_MANDATE: 0.0 (budget is on pool)
|
||||||
|
|
||||||
Uses bulk queries to minimize database connections.
|
Uses bulk queries to minimize database connections.
|
||||||
|
|
||||||
|
|
@ -431,16 +486,23 @@ class BillingObjects:
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
accountsCreated = 0
|
accountsCreated = 0
|
||||||
|
appDb = _getAppDatabaseConnector()
|
||||||
|
rootMandateId = _getCachedRootMandateId()
|
||||||
|
|
||||||
# Step 1: Get all billing settings (all models except UNLIMITED need user accounts)
|
# Step 1: Get all billing settings (all mandates with settings get user accounts)
|
||||||
allSettings = self.db.getRecordset(BillingSettings)
|
allSettings = self.db.getRecordset(BillingSettings)
|
||||||
billingMandates = {} # mandateId -> (billingModel, defaultCredit)
|
billingMandates = {} # mandateId -> (billingModel, defaultCredit)
|
||||||
for s in allSettings:
|
for s in allSettings:
|
||||||
billingModel = s.get("billingModel", BillingModelEnum.UNLIMITED.value)
|
billingModel = parseBillingModelFromStoredValue(s.get("billingModel")).value
|
||||||
if billingModel == BillingModelEnum.UNLIMITED.value:
|
mid = s.get("mandateId")
|
||||||
continue
|
isRoot = rootMandateId is not None and str(mid) == str(rootMandateId)
|
||||||
defaultCredit = s.get("defaultUserCredit", 10.0) if billingModel == BillingModelEnum.PREPAY_USER.value else 0.0
|
if billingModel == BillingModelEnum.PREPAY_USER.value:
|
||||||
billingMandates[s.get("mandateId")] = (billingModel, defaultCredit)
|
defaultCredit = (
|
||||||
|
float(s.get("defaultUserCredit", 0.0) or 0.0) if isRoot else 0.0
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
defaultCredit = 0.0
|
||||||
|
billingMandates[mid] = (billingModel, defaultCredit)
|
||||||
|
|
||||||
if not billingMandates:
|
if not billingMandates:
|
||||||
logger.debug("No billable mandates found, skipping account check")
|
logger.debug("No billable mandates found, skipping account check")
|
||||||
|
|
@ -457,13 +519,6 @@ class BillingObjects:
|
||||||
existingAccountKeys.add(key)
|
existingAccountKeys.add(key)
|
||||||
|
|
||||||
# Step 3: Get all user-mandate combinations from APP database
|
# Step 3: Get all user-mandate combinations from APP database
|
||||||
appDb = DatabaseConnector(
|
|
||||||
dbDatabase=APP_CONFIG.get('DB_DATABASE', 'poweron_app'),
|
|
||||||
dbHost=APP_CONFIG.get('DB_HOST', 'localhost'),
|
|
||||||
dbPort=int(APP_CONFIG.get('DB_PORT', '5432')),
|
|
||||||
dbUser=APP_CONFIG.get('DB_USER'),
|
|
||||||
dbPassword=APP_CONFIG.get('DB_PASSWORD_SECRET')
|
|
||||||
)
|
|
||||||
allUserMandates = appDb.getRecordset(
|
allUserMandates = appDb.getRecordset(
|
||||||
UserMandate,
|
UserMandate,
|
||||||
recordFilter={"enabled": True}
|
recordFilter={"enabled": True}
|
||||||
|
|
@ -711,69 +766,44 @@ class BillingObjects:
|
||||||
"""
|
"""
|
||||||
Check if there's sufficient balance for an operation.
|
Check if there's sufficient balance for an operation.
|
||||||
|
|
||||||
Budget logic:
|
- PREPAY_USER: user.balance >= estimatedCost
|
||||||
- PREPAY_USER: check user's own account balance
|
- PREPAY_MANDATE: mandate pool balance >= estimatedCost
|
||||||
- PREPAY_MANDATE: check mandate pool balance (shared by all users)
|
|
||||||
- CREDIT_POSTPAY: check mandate pool credit limit
|
|
||||||
- UNLIMITED: always allowed
|
|
||||||
|
|
||||||
User accounts are always ensured to exist (for audit trail).
|
User accounts are always ensured to exist (for audit trail).
|
||||||
|
Root mandate + PREPAY_USER: initial credit from settings.defaultUserCredit on first create.
|
||||||
Args:
|
Missing settings: treated as PREPAY_MANDATE with empty pool (strict).
|
||||||
mandateId: Mandate ID
|
|
||||||
userId: User ID
|
|
||||||
estimatedCost: Estimated cost of the operation
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
BillingCheckResult
|
|
||||||
"""
|
"""
|
||||||
settings = self.getSettings(mandateId)
|
settings = self.getSettings(mandateId)
|
||||||
if not settings:
|
if not settings:
|
||||||
return BillingCheckResult(allowed=True, billingModel=BillingModelEnum.UNLIMITED)
|
billingModel = BillingModelEnum.PREPAY_MANDATE
|
||||||
|
defaultCredit = 0.0
|
||||||
|
else:
|
||||||
|
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
|
||||||
|
defaultCredit = float(settings.get("defaultUserCredit", 0.0) or 0.0)
|
||||||
|
|
||||||
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value))
|
rootMandateId = _getCachedRootMandateId()
|
||||||
|
isRootMandate = rootMandateId is not None and str(mandateId) == str(rootMandateId)
|
||||||
if billingModel == BillingModelEnum.UNLIMITED:
|
if billingModel == BillingModelEnum.PREPAY_USER:
|
||||||
return BillingCheckResult(allowed=True, billingModel=billingModel)
|
initialBalance = defaultCredit if isRootMandate else 0.0
|
||||||
|
else:
|
||||||
# Always ensure user account exists (for audit trail)
|
initialBalance = 0.0
|
||||||
defaultCredit = settings.get("defaultUserCredit", 10.0)
|
|
||||||
initialBalance = defaultCredit if billingModel == BillingModelEnum.PREPAY_USER else 0.0
|
|
||||||
self.getOrCreateUserAccount(mandateId, userId, initialBalance=initialBalance)
|
self.getOrCreateUserAccount(mandateId, userId, initialBalance=initialBalance)
|
||||||
|
|
||||||
# Determine which balance to check based on billing model
|
|
||||||
if billingModel == BillingModelEnum.PREPAY_USER:
|
if billingModel == BillingModelEnum.PREPAY_USER:
|
||||||
account = self.getUserAccount(mandateId, userId)
|
account = self.getUserAccount(mandateId, userId)
|
||||||
currentBalance = account.get("balance", 0.0) if account else 0.0
|
currentBalance = account.get("balance", 0.0) if account else 0.0
|
||||||
elif billingModel == BillingModelEnum.PREPAY_MANDATE:
|
|
||||||
poolAccount = self.getOrCreateMandateAccount(mandateId)
|
|
||||||
currentBalance = poolAccount.get("balance", 0.0)
|
|
||||||
elif billingModel == BillingModelEnum.CREDIT_POSTPAY:
|
|
||||||
poolAccount = self.getOrCreateMandateAccount(mandateId)
|
|
||||||
currentBalance = poolAccount.get("balance", 0.0)
|
|
||||||
creditLimit = poolAccount.get("creditLimit")
|
|
||||||
if creditLimit and abs(currentBalance) + estimatedCost > creditLimit:
|
|
||||||
return BillingCheckResult(
|
|
||||||
allowed=False,
|
|
||||||
reason="CREDIT_LIMIT_EXCEEDED",
|
|
||||||
currentBalance=currentBalance,
|
|
||||||
requiredAmount=estimatedCost,
|
|
||||||
billingModel=billingModel
|
|
||||||
)
|
|
||||||
return BillingCheckResult(allowed=True, currentBalance=currentBalance, billingModel=billingModel)
|
|
||||||
else:
|
else:
|
||||||
return BillingCheckResult(allowed=True, billingModel=billingModel)
|
poolAccount = self.getOrCreateMandateAccount(mandateId)
|
||||||
|
currentBalance = poolAccount.get("balance", 0.0)
|
||||||
|
|
||||||
# PREPAY models - check balance
|
|
||||||
if currentBalance < estimatedCost:
|
if currentBalance < estimatedCost:
|
||||||
if settings.get("blockOnZeroBalance", True):
|
return BillingCheckResult(
|
||||||
return BillingCheckResult(
|
allowed=False,
|
||||||
allowed=False,
|
reason="INSUFFICIENT_BALANCE",
|
||||||
reason="INSUFFICIENT_BALANCE",
|
currentBalance=currentBalance,
|
||||||
currentBalance=currentBalance,
|
requiredAmount=estimatedCost,
|
||||||
requiredAmount=estimatedCost,
|
billingModel=billingModel,
|
||||||
billingModel=billingModel
|
)
|
||||||
)
|
|
||||||
|
|
||||||
return BillingCheckResult(allowed=True, currentBalance=currentBalance, billingModel=billingModel)
|
return BillingCheckResult(allowed=True, currentBalance=currentBalance, billingModel=billingModel)
|
||||||
|
|
||||||
|
|
@ -800,7 +830,6 @@ class BillingObjects:
|
||||||
Balance is deducted from the appropriate account based on billing model:
|
Balance is deducted from the appropriate account based on billing model:
|
||||||
- PREPAY_USER: deduct from user's own balance
|
- PREPAY_USER: deduct from user's own balance
|
||||||
- PREPAY_MANDATE: deduct from mandate pool balance
|
- PREPAY_MANDATE: deduct from mandate pool balance
|
||||||
- CREDIT_POSTPAY: deduct from mandate pool balance
|
|
||||||
"""
|
"""
|
||||||
if priceCHF <= 0:
|
if priceCHF <= 0:
|
||||||
return None
|
return None
|
||||||
|
|
@ -810,10 +839,7 @@ class BillingObjects:
|
||||||
logger.debug(f"No billing settings for mandate {mandateId}, skipping usage recording")
|
logger.debug(f"No billing settings for mandate {mandateId}, skipping usage recording")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value))
|
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
|
||||||
|
|
||||||
if billingModel == BillingModelEnum.UNLIMITED:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Transaction is ALWAYS on the user's account (audit trail)
|
# Transaction is ALWAYS on the user's account (audit trail)
|
||||||
userAccount = self.getOrCreateUserAccount(mandateId, userId)
|
userAccount = self.getOrCreateUserAccount(mandateId, userId)
|
||||||
|
|
@ -838,12 +864,11 @@ class BillingObjects:
|
||||||
|
|
||||||
# Determine where to deduct balance
|
# Determine where to deduct balance
|
||||||
if billingModel == BillingModelEnum.PREPAY_USER:
|
if billingModel == BillingModelEnum.PREPAY_USER:
|
||||||
# Deduct from user's own balance
|
|
||||||
return self.createTransaction(transaction)
|
return self.createTransaction(transaction)
|
||||||
else:
|
if billingModel == BillingModelEnum.PREPAY_MANDATE:
|
||||||
# PREPAY_MANDATE / CREDIT_POSTPAY: deduct from mandate pool
|
|
||||||
poolAccount = self.getOrCreateMandateAccount(mandateId)
|
poolAccount = self.getOrCreateMandateAccount(mandateId)
|
||||||
return self.createTransaction(transaction, balanceAccountId=poolAccount["id"])
|
return self.createTransaction(transaction, balanceAccountId=poolAccount["id"])
|
||||||
|
return None
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Workflow Cost Query
|
# Workflow Cost Query
|
||||||
|
|
@ -865,18 +890,10 @@ class BillingObjects:
|
||||||
|
|
||||||
def switchBillingModel(self, mandateId: str, oldModel: BillingModelEnum, newModel: BillingModelEnum) -> Dict[str, Any]:
|
def switchBillingModel(self, mandateId: str, oldModel: BillingModelEnum, newModel: BillingModelEnum) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Switch billing model with automatic budget migration.
|
Switch billing model with budget migration logged as BillingTransactions.
|
||||||
|
|
||||||
MANDATE -> USER: pool balance is distributed equally to all user accounts.
|
PREPAY_MANDATE -> PREPAY_USER: pool debited, equal shares credited to user accounts.
|
||||||
USER -> MANDATE: all user balances are consolidated into the pool, user balances set to 0.
|
PREPAY_USER -> PREPAY_MANDATE: user wallets debited, pool credited with sum.
|
||||||
|
|
||||||
Args:
|
|
||||||
mandateId: Mandate ID
|
|
||||||
oldModel: Current billing model
|
|
||||||
newModel: New billing model
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Migration result dict with details
|
|
||||||
"""
|
"""
|
||||||
result = {"oldModel": oldModel.value, "newModel": newModel.value, "migratedAmount": 0.0, "userCount": 0}
|
result = {"oldModel": oldModel.value, "newModel": newModel.value, "migratedAmount": 0.0, "userCount": 0}
|
||||||
|
|
||||||
|
|
@ -884,47 +901,91 @@ class BillingObjects:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
if oldModel == BillingModelEnum.PREPAY_MANDATE and newModel == BillingModelEnum.PREPAY_USER:
|
if oldModel == BillingModelEnum.PREPAY_MANDATE and newModel == BillingModelEnum.PREPAY_USER:
|
||||||
# Pool -> distribute equally to users
|
|
||||||
poolAccount = self.getMandateAccount(mandateId)
|
poolAccount = self.getMandateAccount(mandateId)
|
||||||
if poolAccount and poolAccount.get("balance", 0.0) > 0:
|
userAccounts = self.db.getRecordset(
|
||||||
poolBalance = poolAccount["balance"]
|
BillingAccount,
|
||||||
userAccounts = self.db.getRecordset(
|
recordFilter={"mandateId": mandateId, "accountType": AccountTypeEnum.USER.value}
|
||||||
BillingAccount,
|
)
|
||||||
recordFilter={"mandateId": mandateId, "accountType": AccountTypeEnum.USER.value}
|
poolBalance = poolAccount.get("balance", 0.0) if poolAccount else 0.0
|
||||||
|
n = len(userAccounts)
|
||||||
|
if poolAccount and poolBalance > 0:
|
||||||
|
self.createTransaction(
|
||||||
|
BillingTransaction(
|
||||||
|
accountId=poolAccount["id"],
|
||||||
|
transactionType=TransactionTypeEnum.DEBIT,
|
||||||
|
amount=poolBalance,
|
||||||
|
description="Model switch: distributed from mandate pool to user wallets",
|
||||||
|
referenceType=ReferenceTypeEnum.SYSTEM,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if userAccounts:
|
result["migratedAmount"] = poolBalance
|
||||||
perUser = poolBalance / len(userAccounts)
|
if n > 0:
|
||||||
for acc in userAccounts:
|
remaining = poolBalance
|
||||||
newBalance = acc.get("balance", 0.0) + perUser
|
for i, acc in enumerate(userAccounts):
|
||||||
self.updateAccountBalance(acc["id"], newBalance)
|
if i == n - 1:
|
||||||
self.updateAccountBalance(poolAccount["id"], 0.0)
|
share = round(remaining, 4)
|
||||||
result["migratedAmount"] = poolBalance
|
else:
|
||||||
result["userCount"] = len(userAccounts)
|
share = round(poolBalance / n, 4)
|
||||||
|
remaining -= share
|
||||||
|
if share > 0:
|
||||||
|
self.createTransaction(
|
||||||
|
BillingTransaction(
|
||||||
|
accountId=acc["id"],
|
||||||
|
transactionType=TransactionTypeEnum.CREDIT,
|
||||||
|
amount=share,
|
||||||
|
description="Model switch: share from mandate pool",
|
||||||
|
referenceType=ReferenceTypeEnum.SYSTEM,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result["userCount"] = n
|
||||||
|
logger.info(
|
||||||
|
"Switched %s MANDATE->USER: migrated %.4f CHF to %d user account(s) (transactions logged)",
|
||||||
|
mandateId,
|
||||||
|
result["migratedAmount"],
|
||||||
|
result["userCount"],
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
logger.info(f"Switched {mandateId} MANDATE->USER: distributed {result['migratedAmount']} CHF to {result['userCount']} users")
|
if oldModel == BillingModelEnum.PREPAY_USER and newModel == BillingModelEnum.PREPAY_MANDATE:
|
||||||
|
|
||||||
elif oldModel == BillingModelEnum.PREPAY_USER and newModel == BillingModelEnum.PREPAY_MANDATE:
|
|
||||||
# Users -> consolidate into pool
|
|
||||||
userAccounts = self.db.getRecordset(
|
userAccounts = self.db.getRecordset(
|
||||||
BillingAccount,
|
BillingAccount,
|
||||||
recordFilter={"mandateId": mandateId, "accountType": AccountTypeEnum.USER.value}
|
recordFilter={"mandateId": mandateId, "accountType": AccountTypeEnum.USER.value}
|
||||||
)
|
)
|
||||||
totalUserBalance = sum(acc.get("balance", 0.0) for acc in userAccounts)
|
totalUserBalance = sum(acc.get("balance", 0.0) for acc in userAccounts)
|
||||||
|
|
||||||
poolAccount = self.getOrCreateMandateAccount(mandateId, initialBalance=0.0)
|
|
||||||
newPoolBalance = poolAccount.get("balance", 0.0) + totalUserBalance
|
|
||||||
self.updateAccountBalance(poolAccount["id"], newPoolBalance)
|
|
||||||
|
|
||||||
for acc in userAccounts:
|
for acc in userAccounts:
|
||||||
self.updateAccountBalance(acc["id"], 0.0)
|
b = acc.get("balance", 0.0)
|
||||||
|
if b > 0:
|
||||||
|
self.createTransaction(
|
||||||
|
BillingTransaction(
|
||||||
|
accountId=acc["id"],
|
||||||
|
transactionType=TransactionTypeEnum.DEBIT,
|
||||||
|
amount=b,
|
||||||
|
description="Model switch: consolidated to mandate pool",
|
||||||
|
referenceType=ReferenceTypeEnum.SYSTEM,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
poolAccount = self.getOrCreateMandateAccount(mandateId, initialBalance=0.0)
|
||||||
|
if totalUserBalance > 0:
|
||||||
|
self.createTransaction(
|
||||||
|
BillingTransaction(
|
||||||
|
accountId=poolAccount["id"],
|
||||||
|
transactionType=TransactionTypeEnum.CREDIT,
|
||||||
|
amount=totalUserBalance,
|
||||||
|
description="Model switch: consolidated from user accounts",
|
||||||
|
referenceType=ReferenceTypeEnum.SYSTEM,
|
||||||
|
)
|
||||||
|
)
|
||||||
result["migratedAmount"] = totalUserBalance
|
result["migratedAmount"] = totalUserBalance
|
||||||
result["userCount"] = len(userAccounts)
|
result["userCount"] = len(userAccounts)
|
||||||
|
logger.info(
|
||||||
|
"Switched %s USER->MANDATE: consolidated %.4f CHF from %d users into pool (transactions logged)",
|
||||||
|
mandateId,
|
||||||
|
totalUserBalance,
|
||||||
|
len(userAccounts),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
logger.info(f"Switched {mandateId} USER->MANDATE: consolidated {totalUserBalance} CHF from {len(userAccounts)} users into pool")
|
if newModel == BillingModelEnum.PREPAY_MANDATE:
|
||||||
|
|
||||||
elif newModel == BillingModelEnum.PREPAY_MANDATE or newModel == BillingModelEnum.CREDIT_POSTPAY:
|
|
||||||
# Any -> MANDATE/CREDIT: ensure pool account exists
|
|
||||||
self.getOrCreateMandateAccount(mandateId, initialBalance=0.0)
|
self.getOrCreateMandateAccount(mandateId, initialBalance=0.0)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
@ -1027,8 +1088,6 @@ class BillingObjects:
|
||||||
Shows the effective available budget:
|
Shows the effective available budget:
|
||||||
- PREPAY_USER: user's own account balance
|
- PREPAY_USER: user's own account balance
|
||||||
- PREPAY_MANDATE: mandate pool balance (shared budget visible to user)
|
- PREPAY_MANDATE: mandate pool balance (shared budget visible to user)
|
||||||
- CREDIT_POSTPAY: mandate pool balance
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
userId: User ID
|
userId: User ID
|
||||||
|
|
||||||
|
|
@ -1060,25 +1119,20 @@ class BillingObjects:
|
||||||
if not settings:
|
if not settings:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value))
|
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
|
||||||
if billingModel == BillingModelEnum.UNLIMITED:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Determine effective balance based on billing model
|
|
||||||
if billingModel == BillingModelEnum.PREPAY_USER:
|
if billingModel == BillingModelEnum.PREPAY_USER:
|
||||||
account = self.getOrCreateUserAccount(mandateId, userId)
|
account = self.getOrCreateUserAccount(mandateId, userId)
|
||||||
if not account:
|
if not account:
|
||||||
continue
|
continue
|
||||||
balance = account.get("balance", 0.0)
|
balance = account.get("balance", 0.0)
|
||||||
warningThreshold = account.get("warningThreshold", 0.0)
|
warningThreshold = account.get("warningThreshold", 0.0)
|
||||||
creditLimit = account.get("creditLimit")
|
elif billingModel == BillingModelEnum.PREPAY_MANDATE:
|
||||||
elif billingModel in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]:
|
|
||||||
poolAccount = self.getOrCreateMandateAccount(mandateId)
|
poolAccount = self.getOrCreateMandateAccount(mandateId)
|
||||||
if not poolAccount:
|
if not poolAccount:
|
||||||
continue
|
continue
|
||||||
balance = poolAccount.get("balance", 0.0)
|
balance = poolAccount.get("balance", 0.0)
|
||||||
warningThreshold = poolAccount.get("warningThreshold", 0.0)
|
warningThreshold = poolAccount.get("warningThreshold", 0.0)
|
||||||
creditLimit = poolAccount.get("creditLimit")
|
|
||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -1089,7 +1143,6 @@ class BillingObjects:
|
||||||
balance=balance,
|
balance=balance,
|
||||||
warningThreshold=warningThreshold,
|
warningThreshold=warningThreshold,
|
||||||
isWarning=balance <= warningThreshold,
|
isWarning=balance <= warningThreshold,
|
||||||
creditLimit=creditLimit
|
|
||||||
))
|
))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting balances for user: {e}")
|
logger.error(f"Error getting balances for user: {e}")
|
||||||
|
|
@ -1183,7 +1236,7 @@ class BillingObjects:
|
||||||
if not mandateId:
|
if not mandateId:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value))
|
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
|
||||||
|
|
||||||
# Get mandate info
|
# Get mandate info
|
||||||
mandate = appInterface.getMandate(mandateId)
|
mandate = appInterface.getMandate(mandateId)
|
||||||
|
|
@ -1198,12 +1251,9 @@ class BillingObjects:
|
||||||
)
|
)
|
||||||
userCount = len(userAccounts)
|
userCount = len(userAccounts)
|
||||||
|
|
||||||
# Total balance depends on billing model
|
|
||||||
if billingModel == BillingModelEnum.PREPAY_USER:
|
if billingModel == BillingModelEnum.PREPAY_USER:
|
||||||
# Budget is distributed across user accounts
|
|
||||||
totalBalance = sum(acc.get("balance", 0.0) for acc in userAccounts)
|
totalBalance = sum(acc.get("balance", 0.0) for acc in userAccounts)
|
||||||
elif billingModel in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]:
|
elif billingModel == BillingModelEnum.PREPAY_MANDATE:
|
||||||
# Budget is in the mandate pool
|
|
||||||
poolAccount = self.getMandateAccount(mandateId)
|
poolAccount = self.getMandateAccount(mandateId)
|
||||||
totalBalance = poolAccount.get("balance", 0.0) if poolAccount else 0.0
|
totalBalance = poolAccount.get("balance", 0.0) if poolAccount else 0.0
|
||||||
else:
|
else:
|
||||||
|
|
@ -1215,9 +1265,8 @@ class BillingObjects:
|
||||||
"billingModel": billingModel.value,
|
"billingModel": billingModel.value,
|
||||||
"totalBalance": totalBalance,
|
"totalBalance": totalBalance,
|
||||||
"userCount": userCount,
|
"userCount": userCount,
|
||||||
"defaultUserCredit": settings.get("defaultUserCredit", 0.0),
|
"defaultUserCredit": float(settings.get("defaultUserCredit", 0.0) or 0.0),
|
||||||
"warningThresholdPercent": settings.get("warningThresholdPercent", 10.0),
|
"warningThresholdPercent": settings.get("warningThresholdPercent", 10.0),
|
||||||
"blockOnZeroBalance": settings.get("blockOnZeroBalance", True)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ Data Namespace Structure:
|
||||||
|
|
||||||
GROUP-Berechtigung:
|
GROUP-Berechtigung:
|
||||||
- data.uam.*: GROUP filtert nach Mandant (via UserMandate)
|
- data.uam.*: GROUP filtert nach Mandant (via UserMandate)
|
||||||
- data.chat.*, data.files.*, data.automation.*: GROUP = MY (benutzer-eigen, kein Mandantenkontext)
|
- data.chat.*, data.files.*, data.automation.*: GROUP = MY (benutzer-eigen); bei gesetztem featureInstanceId zusätzlich _createdBy
|
||||||
- data.feature.*: GROUP filtert nach mandateId/featureInstanceId
|
- data.feature.*: GROUP filtert nach mandateId/featureInstanceId
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -344,6 +344,20 @@ def buildRbacWhereClause(
|
||||||
|
|
||||||
# All records within the feature instance - only featureInstanceId filtering
|
# All records within the feature instance - only featureInstanceId filtering
|
||||||
if readLevel == AccessLevel.ALL:
|
if readLevel == AccessLevel.ALL:
|
||||||
|
# Chat / AI Workspace: even DATA read ALL must not list other users' rows in a
|
||||||
|
# shared featureInstance (stale RBAC rules or merged roles). Same as MY.
|
||||||
|
namespaceAll = TABLE_NAMESPACE.get(table, "system")
|
||||||
|
if featureInstanceId and namespaceAll == "chat":
|
||||||
|
userIdFieldAll = "_createdBy"
|
||||||
|
if table == "UserInDB":
|
||||||
|
userIdFieldAll = "id"
|
||||||
|
elif table == "UserConnection":
|
||||||
|
userIdFieldAll = "userId"
|
||||||
|
conditionsAll = list(baseConditions)
|
||||||
|
valuesAll = list(baseValues)
|
||||||
|
conditionsAll.append(f'"{userIdFieldAll}" = %s')
|
||||||
|
valuesAll.append(currentUser.id)
|
||||||
|
return {"condition": " AND ".join(conditionsAll), "values": valuesAll}
|
||||||
if baseConditions:
|
if baseConditions:
|
||||||
return {"condition": " AND ".join(baseConditions), "values": baseValues}
|
return {"condition": " AND ".join(baseConditions), "values": baseValues}
|
||||||
return None
|
return None
|
||||||
|
|
@ -379,6 +393,14 @@ def buildRbacWhereClause(
|
||||||
# But still apply featureInstanceId filter if provided
|
# But still apply featureInstanceId filter if provided
|
||||||
if namespace in USER_OWNED_NAMESPACES:
|
if namespace in USER_OWNED_NAMESPACES:
|
||||||
if baseConditions:
|
if baseConditions:
|
||||||
|
# Shared feature instance: GROUP would otherwise only filter by featureInstanceId
|
||||||
|
# and expose every user's rows in that instance (e.g. ChatWorkflow).
|
||||||
|
if featureInstanceId and readLevel == AccessLevel.GROUP:
|
||||||
|
conditions = list(baseConditions)
|
||||||
|
values = list(baseValues)
|
||||||
|
conditions.append('"_createdBy" = %s')
|
||||||
|
values.append(currentUser.id)
|
||||||
|
return {"condition": " AND ".join(conditions), "values": values}
|
||||||
return {"condition": " AND ".join(baseConditions), "values": baseValues}
|
return {"condition": " AND ".join(baseConditions), "values": baseValues}
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,18 @@ from modules.datamodels.datamodelFeatures import Feature, FeatureInstance
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
from modules.interfaces.interfaceFeatures import getFeatureInterface
|
from modules.interfaces.interfaceFeatures import getFeatureInterface
|
||||||
from modules.security.rbacCatalog import getCatalogService
|
from modules.security.rbacCatalog import getCatalogService
|
||||||
|
from modules.routes.routeNotifications import create_access_change_notification
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _feature_instance_display_name(instance: Any) -> str:
|
||||||
|
if instance is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(instance, dict):
|
||||||
|
return str(instance.get("label") or instance.get("uiLabel") or instance.get("id", ""))
|
||||||
|
return str(getattr(instance, "label", None) or getattr(instance, "uiLabel", None) or getattr(instance, "id", ""))
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/api/features",
|
prefix="/api/features",
|
||||||
tags=["Features"],
|
tags=["Features"],
|
||||||
|
|
@ -1025,6 +1034,15 @@ def add_user_to_feature_instance(
|
||||||
f"with roles {data.roleIds}"
|
f"with roles {data.roleIds}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
iname = _feature_instance_display_name(instance)
|
||||||
|
create_access_change_notification(
|
||||||
|
data.userId,
|
||||||
|
"Feature-Zugriff",
|
||||||
|
f"Sie haben Zugriff auf die Feature-Instanz «{iname}» erhalten.",
|
||||||
|
"feature_access",
|
||||||
|
instanceId,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"featureAccessId": featureAccessId,
|
"featureAccessId": featureAccessId,
|
||||||
"userId": data.userId,
|
"userId": data.userId,
|
||||||
|
|
@ -1105,6 +1123,15 @@ def remove_user_from_feature_instance(
|
||||||
f"User {context.user.id} removed user {userId} from feature instance {instanceId}"
|
f"User {context.user.id} removed user {userId} from feature instance {instanceId}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
iname = _feature_instance_display_name(instance)
|
||||||
|
create_access_change_notification(
|
||||||
|
userId,
|
||||||
|
"Feature-Zugriff",
|
||||||
|
f"Ihr Zugriff auf die Feature-Instanz «{iname}» wurde entfernt.",
|
||||||
|
"feature_access",
|
||||||
|
instanceId,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"message": "User access removed",
|
"message": "User access removed",
|
||||||
"userId": userId,
|
"userId": userId,
|
||||||
|
|
@ -1198,6 +1225,15 @@ def update_feature_instance_user_roles(
|
||||||
f"User {context.user.id} updated roles for user {userId} in feature instance {instanceId}: {data.roleIds}"
|
f"User {context.user.id} updated roles for user {userId} in feature instance {instanceId}: {data.roleIds}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
iname = _feature_instance_display_name(instance)
|
||||||
|
create_access_change_notification(
|
||||||
|
userId,
|
||||||
|
"Feature-Rollen geändert",
|
||||||
|
f"Ihre Rollen in der Feature-Instanz «{iname}» wurden angepasst.",
|
||||||
|
"feature_access",
|
||||||
|
instanceId,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"featureAccessId": featureAccessId,
|
"featureAccessId": featureAccessId,
|
||||||
"userId": userId,
|
"userId": userId,
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ from modules.datamodels.datamodelBilling import (
|
||||||
BillingAccount,
|
BillingAccount,
|
||||||
BillingTransaction,
|
BillingTransaction,
|
||||||
BillingSettings,
|
BillingSettings,
|
||||||
BillingAddress,
|
|
||||||
BillingModelEnum,
|
BillingModelEnum,
|
||||||
TransactionTypeEnum,
|
TransactionTypeEnum,
|
||||||
ReferenceTypeEnum,
|
ReferenceTypeEnum,
|
||||||
|
|
@ -37,6 +36,7 @@ from modules.datamodels.datamodelBilling import (
|
||||||
BillingStatisticsResponse,
|
BillingStatisticsResponse,
|
||||||
BillingStatisticsChartData,
|
BillingStatisticsChartData,
|
||||||
BillingCheckResult,
|
BillingCheckResult,
|
||||||
|
parseBillingModelFromStoredValue,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Configure logger
|
# Configure logger
|
||||||
|
|
@ -263,10 +263,8 @@ class BillingSettingsUpdate(BaseModel):
|
||||||
billingModel: Optional[BillingModelEnum] = None
|
billingModel: Optional[BillingModelEnum] = None
|
||||||
defaultUserCredit: Optional[float] = Field(None, ge=0)
|
defaultUserCredit: Optional[float] = Field(None, ge=0)
|
||||||
warningThresholdPercent: Optional[float] = Field(None, ge=0, le=100)
|
warningThresholdPercent: Optional[float] = Field(None, ge=0, le=100)
|
||||||
blockOnZeroBalance: Optional[bool] = None
|
|
||||||
notifyOnWarning: Optional[bool] = None
|
notifyOnWarning: Optional[bool] = None
|
||||||
notifyEmails: Optional[List[str]] = None
|
notifyEmails: Optional[List[str]] = None
|
||||||
billingAddress: Optional[BillingAddress] = None
|
|
||||||
|
|
||||||
|
|
||||||
class TransactionResponse(BaseModel):
|
class TransactionResponse(BaseModel):
|
||||||
|
|
@ -295,7 +293,6 @@ class AccountSummary(BaseModel):
|
||||||
userId: Optional[str]
|
userId: Optional[str]
|
||||||
accountType: str
|
accountType: str
|
||||||
balance: float
|
balance: float
|
||||||
creditLimit: Optional[float]
|
|
||||||
warningThreshold: float
|
warningThreshold: float
|
||||||
enabled: bool
|
enabled: bool
|
||||||
|
|
||||||
|
|
@ -323,7 +320,6 @@ class MandateBalanceResponse(BaseModel):
|
||||||
userCount: int
|
userCount: int
|
||||||
defaultUserCredit: float
|
defaultUserCredit: float
|
||||||
warningThresholdPercent: float
|
warningThresholdPercent: float
|
||||||
blockOnZeroBalance: bool
|
|
||||||
|
|
||||||
|
|
||||||
class UserBalanceResponse(BaseModel):
|
class UserBalanceResponse(BaseModel):
|
||||||
|
|
@ -427,12 +423,12 @@ def _creditStripeSessionIfNeeded(
|
||||||
if not settings:
|
if not settings:
|
||||||
raise HTTPException(status_code=404, detail="Billing settings not found")
|
raise HTTPException(status_code=404, detail="Billing settings not found")
|
||||||
|
|
||||||
billing_model = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value))
|
billing_model = parseBillingModelFromStoredValue(settings.get("billingModel"))
|
||||||
if billing_model == BillingModelEnum.PREPAY_USER:
|
if billing_model == BillingModelEnum.PREPAY_USER:
|
||||||
if not user_id:
|
if not user_id:
|
||||||
raise HTTPException(status_code=400, detail="userId required for PREPAY_USER")
|
raise HTTPException(status_code=400, detail="userId required for PREPAY_USER")
|
||||||
account = billingInterface.getOrCreateUserAccount(mandate_id, user_id, initialBalance=0.0)
|
account = billingInterface.getOrCreateUserAccount(mandate_id, user_id, initialBalance=0.0)
|
||||||
elif billing_model in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]:
|
elif billing_model == BillingModelEnum.PREPAY_MANDATE:
|
||||||
account = billingInterface.getOrCreateMandateAccount(mandate_id, initialBalance=0.0)
|
account = billingInterface.getOrCreateMandateAccount(mandate_id, initialBalance=0.0)
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=400, detail=f"Cannot add credit to {billing_model.value}")
|
raise HTTPException(status_code=400, detail=f"Cannot add credit to {billing_model.value}")
|
||||||
|
|
@ -529,11 +525,10 @@ def getBalanceForMandate(
|
||||||
return BillingBalanceResponse(
|
return BillingBalanceResponse(
|
||||||
mandateId=targetMandateId,
|
mandateId=targetMandateId,
|
||||||
mandateName=mandateName,
|
mandateName=mandateName,
|
||||||
billingModel=checkResult.billingModel or BillingModelEnum.UNLIMITED,
|
billingModel=checkResult.billingModel or BillingModelEnum.PREPAY_MANDATE,
|
||||||
balance=checkResult.currentBalance or 0.0,
|
balance=checkResult.currentBalance or 0.0,
|
||||||
warningThreshold=0.0, # TODO: Get from account
|
warningThreshold=0.0, # TODO: Get from account
|
||||||
isWarning=False,
|
isWarning=False,
|
||||||
creditLimit=None
|
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -622,7 +617,7 @@ def getStatistics(
|
||||||
costByFeature={}
|
costByFeature={}
|
||||||
)
|
)
|
||||||
|
|
||||||
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value))
|
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
|
||||||
|
|
||||||
# Transactions are always on user accounts (audit trail)
|
# Transactions are always on user accounts (audit trail)
|
||||||
account = billingInterface.getUserAccount(ctx.mandateId, ctx.user.id)
|
account = billingInterface.getUserAccount(ctx.mandateId, ctx.user.id)
|
||||||
|
|
@ -750,8 +745,12 @@ def createOrUpdateSettings(
|
||||||
if updates:
|
if updates:
|
||||||
# Check if billing model is changing - trigger budget migration
|
# Check if billing model is changing - trigger budget migration
|
||||||
if "billingModel" in updates:
|
if "billingModel" in updates:
|
||||||
oldModel = BillingModelEnum(existingSettings.get("billingModel", BillingModelEnum.UNLIMITED.value))
|
oldModel = parseBillingModelFromStoredValue(existingSettings.get("billingModel"))
|
||||||
newModel = BillingModelEnum(updates["billingModel"]) if isinstance(updates["billingModel"], str) else updates["billingModel"]
|
newModel = (
|
||||||
|
BillingModelEnum(updates["billingModel"])
|
||||||
|
if isinstance(updates["billingModel"], str)
|
||||||
|
else updates["billingModel"]
|
||||||
|
)
|
||||||
if oldModel != newModel:
|
if oldModel != newModel:
|
||||||
migrationResult = billingInterface.switchBillingModel(targetMandateId, oldModel, newModel)
|
migrationResult = billingInterface.switchBillingModel(targetMandateId, oldModel, newModel)
|
||||||
logger.info(f"Billing model migration for {targetMandateId}: {migrationResult}")
|
logger.info(f"Billing model migration for {targetMandateId}: {migrationResult}")
|
||||||
|
|
@ -764,13 +763,27 @@ def createOrUpdateSettings(
|
||||||
|
|
||||||
newSettings = BillingSettings(
|
newSettings = BillingSettings(
|
||||||
mandateId=targetMandateId,
|
mandateId=targetMandateId,
|
||||||
billingModel=settingsUpdate.billingModel or BillingModelEnum.UNLIMITED,
|
billingModel=(
|
||||||
defaultUserCredit=settingsUpdate.defaultUserCredit or 10.0,
|
settingsUpdate.billingModel
|
||||||
warningThresholdPercent=settingsUpdate.warningThresholdPercent or 10.0,
|
if settingsUpdate.billingModel is not None
|
||||||
blockOnZeroBalance=settingsUpdate.blockOnZeroBalance if settingsUpdate.blockOnZeroBalance is not None else True,
|
else BillingModelEnum.PREPAY_MANDATE
|
||||||
notifyOnWarning=settingsUpdate.notifyOnWarning if settingsUpdate.notifyOnWarning is not None else True,
|
),
|
||||||
|
defaultUserCredit=(
|
||||||
|
settingsUpdate.defaultUserCredit
|
||||||
|
if settingsUpdate.defaultUserCredit is not None
|
||||||
|
else 0.0
|
||||||
|
),
|
||||||
|
warningThresholdPercent=(
|
||||||
|
settingsUpdate.warningThresholdPercent
|
||||||
|
if settingsUpdate.warningThresholdPercent is not None
|
||||||
|
else 10.0
|
||||||
|
),
|
||||||
|
notifyOnWarning=(
|
||||||
|
settingsUpdate.notifyOnWarning
|
||||||
|
if settingsUpdate.notifyOnWarning is not None
|
||||||
|
else True
|
||||||
|
),
|
||||||
notifyEmails=settingsUpdate.notifyEmails or [],
|
notifyEmails=settingsUpdate.notifyEmails or [],
|
||||||
billingAddress=settingsUpdate.billingAddress
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return billingInterface.createSettings(newSettings)
|
return billingInterface.createSettings(newSettings)
|
||||||
|
|
@ -803,7 +816,7 @@ def addCredit(
|
||||||
if not settings:
|
if not settings:
|
||||||
raise HTTPException(status_code=404, detail="Billing settings not found for this mandate")
|
raise HTTPException(status_code=404, detail="Billing settings not found for this mandate")
|
||||||
|
|
||||||
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value))
|
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
|
||||||
|
|
||||||
# Validate request based on billing model
|
# Validate request based on billing model
|
||||||
if billingModel == BillingModelEnum.PREPAY_USER:
|
if billingModel == BillingModelEnum.PREPAY_USER:
|
||||||
|
|
@ -816,7 +829,7 @@ def addCredit(
|
||||||
creditRequest.userId,
|
creditRequest.userId,
|
||||||
initialBalance=0.0
|
initialBalance=0.0
|
||||||
)
|
)
|
||||||
elif billingModel in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]:
|
elif billingModel == BillingModelEnum.PREPAY_MANDATE:
|
||||||
# Create mandate-level account if needed and add credit
|
# Create mandate-level account if needed and add credit
|
||||||
account = billingInterface.getOrCreateMandateAccount(targetMandateId, initialBalance=0.0)
|
account = billingInterface.getOrCreateMandateAccount(targetMandateId, initialBalance=0.0)
|
||||||
else:
|
else:
|
||||||
|
|
@ -866,7 +879,7 @@ def createCheckoutSession(
|
||||||
if not settings:
|
if not settings:
|
||||||
raise HTTPException(status_code=404, detail="Billing settings not found for this mandate")
|
raise HTTPException(status_code=404, detail="Billing settings not found for this mandate")
|
||||||
|
|
||||||
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value))
|
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
|
||||||
|
|
||||||
if billingModel == BillingModelEnum.PREPAY_USER:
|
if billingModel == BillingModelEnum.PREPAY_USER:
|
||||||
if not checkoutRequest.userId:
|
if not checkoutRequest.userId:
|
||||||
|
|
@ -875,7 +888,7 @@ def createCheckoutSession(
|
||||||
raise HTTPException(status_code=403, detail="Users can only load credit to their own account")
|
raise HTTPException(status_code=403, detail="Users can only load credit to their own account")
|
||||||
if not _isMemberOfMandate(ctx, targetMandateId):
|
if not _isMemberOfMandate(ctx, targetMandateId):
|
||||||
raise HTTPException(status_code=403, detail="User is not a member of this mandate")
|
raise HTTPException(status_code=403, detail="User is not a member of this mandate")
|
||||||
elif billingModel in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]:
|
elif billingModel == BillingModelEnum.PREPAY_MANDATE:
|
||||||
if not _isAdminOfMandate(ctx, targetMandateId):
|
if not _isAdminOfMandate(ctx, targetMandateId):
|
||||||
raise HTTPException(status_code=403, detail="Mandate admin role required to load mandate credit")
|
raise HTTPException(status_code=403, detail="Mandate admin role required to load mandate credit")
|
||||||
else:
|
else:
|
||||||
|
|
@ -933,7 +946,7 @@ def confirmCheckoutSession(
|
||||||
if not settings:
|
if not settings:
|
||||||
raise HTTPException(status_code=404, detail="Billing settings not found")
|
raise HTTPException(status_code=404, detail="Billing settings not found")
|
||||||
|
|
||||||
billing_model = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value))
|
billing_model = parseBillingModelFromStoredValue(settings.get("billingModel"))
|
||||||
if billing_model == BillingModelEnum.PREPAY_USER:
|
if billing_model == BillingModelEnum.PREPAY_USER:
|
||||||
if not user_id:
|
if not user_id:
|
||||||
raise HTTPException(status_code=400, detail="userId required for PREPAY_USER")
|
raise HTTPException(status_code=400, detail="userId required for PREPAY_USER")
|
||||||
|
|
@ -941,7 +954,7 @@ def confirmCheckoutSession(
|
||||||
raise HTTPException(status_code=403, detail="Users can only confirm their own payment sessions")
|
raise HTTPException(status_code=403, detail="Users can only confirm their own payment sessions")
|
||||||
if not _isMemberOfMandate(ctx, mandate_id):
|
if not _isMemberOfMandate(ctx, mandate_id):
|
||||||
raise HTTPException(status_code=403, detail="User is not a member of this mandate")
|
raise HTTPException(status_code=403, detail="User is not a member of this mandate")
|
||||||
elif billing_model in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]:
|
elif billing_model == BillingModelEnum.PREPAY_MANDATE:
|
||||||
if not _isAdminOfMandate(ctx, mandate_id):
|
if not _isAdminOfMandate(ctx, mandate_id):
|
||||||
raise HTTPException(status_code=403, detail="Mandate admin role required")
|
raise HTTPException(status_code=403, detail="Mandate admin role required")
|
||||||
else:
|
else:
|
||||||
|
|
@ -1041,7 +1054,6 @@ def getAccounts(
|
||||||
userId=acc.get("userId"),
|
userId=acc.get("userId"),
|
||||||
accountType=acc.get("accountType"),
|
accountType=acc.get("accountType"),
|
||||||
balance=acc.get("balance", 0.0),
|
balance=acc.get("balance", 0.0),
|
||||||
creditLimit=acc.get("creditLimit"),
|
|
||||||
warningThreshold=acc.get("warningThreshold", 0.0),
|
warningThreshold=acc.get("warningThreshold", 0.0),
|
||||||
enabled=acc.get("enabled", True)
|
enabled=acc.get("enabled", True)
|
||||||
))
|
))
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ from fastapi import status
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from modules.datamodels.datamodelUam import User, UserConnection, AuthAuthority, ConnectionStatus
|
from modules.datamodels.datamodelUam import User, UserConnection, AuthAuthority, ConnectionStatus
|
||||||
from modules.datamodels.datamodelSecurity import Token
|
from modules.datamodels.datamodelSecurity import Token
|
||||||
|
|
@ -445,24 +446,12 @@ def connect_service(
|
||||||
detail="Connection not found"
|
detail="Connection not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Initiate OAuth flow with state=connect
|
# Data-app OAuth (JWT state issued server-side in /auth/connect)
|
||||||
auth_url = None
|
auth_url = None
|
||||||
if connection.authority == AuthAuthority.MSFT:
|
if connection.authority == AuthAuthority.MSFT:
|
||||||
# Use the same login endpoint with state=connect to ensure account selection
|
auth_url = f"/api/msft/auth/connect?connectionId={quote(connectionId, safe='')}"
|
||||||
# Include current user ID in state
|
|
||||||
state_data = {
|
|
||||||
"type": "connect",
|
|
||||||
"connectionId": connectionId,
|
|
||||||
"userId": currentUser.id # Add current user ID
|
|
||||||
}
|
|
||||||
auth_url = f"/api/msft/login?state={json.dumps(state_data)}"
|
|
||||||
elif connection.authority == AuthAuthority.GOOGLE:
|
elif connection.authority == AuthAuthority.GOOGLE:
|
||||||
state_data = {
|
auth_url = f"/api/google/auth/connect?connectionId={quote(connectionId, safe='')}"
|
||||||
"type": "connect",
|
|
||||||
"connectionId": connectionId,
|
|
||||||
"userId": currentUser.id # Add current user ID
|
|
||||||
}
|
|
||||||
auth_url = f"/api/google/login?state={json.dumps(state_data)}"
|
|
||||||
else:
|
else:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ from modules.auth import limiter, requireSysAdminRole, getRequestContext, Reques
|
||||||
|
|
||||||
# Import interfaces
|
# Import interfaces
|
||||||
import modules.interfaces.interfaceDbApp as interfaceDbApp
|
import modules.interfaces.interfaceDbApp as interfaceDbApp
|
||||||
|
from modules.interfaces.interfaceDbBilling import _getRootInterface as _getBillingRootInterface
|
||||||
from modules.shared.attributeUtils import getModelAttributeDefinitions
|
from modules.shared.attributeUtils import getModelAttributeDefinitions
|
||||||
from modules.shared.auditLogger import audit_logger
|
from modules.shared.auditLogger import audit_logger
|
||||||
|
|
||||||
|
|
@ -29,6 +30,7 @@ from modules.datamodels.datamodelUam import Mandate, User
|
||||||
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
|
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
|
||||||
from modules.datamodels.datamodelRbac import Role
|
from modules.datamodels.datamodelRbac import Role
|
||||||
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
|
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
|
||||||
|
from modules.routes.routeNotifications import create_access_change_notification
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -248,6 +250,15 @@ def create_mandate(
|
||||||
detail="Failed to create mandate"
|
detail="Failed to create mandate"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
billingInterface = _getBillingRootInterface()
|
||||||
|
billingInterface.getOrCreateSettings(str(newMandate.id))
|
||||||
|
logger.debug(f"Ensured billing settings for new mandate {newMandate.id}")
|
||||||
|
except Exception as billingErr:
|
||||||
|
logger.warning(
|
||||||
|
f"Could not create default billing settings for mandate {newMandate.id}: {billingErr}"
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(f"Mandate {newMandate.id} created by SysAdmin {currentUser.id}")
|
logger.info(f"Mandate {newMandate.id} created by SysAdmin {currentUser.id}")
|
||||||
|
|
||||||
return newMandate
|
return newMandate
|
||||||
|
|
@ -613,6 +624,15 @@ def add_user_to_mandate(
|
||||||
f"with roles {data.roleIds}"
|
f"with roles {data.roleIds}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
mname = _mandate_display_name(mandate)
|
||||||
|
create_access_change_notification(
|
||||||
|
data.targetUserId,
|
||||||
|
"Mandantenzugriff",
|
||||||
|
f"Sie wurden dem Mandanten «{mname}» hinzugefügt.",
|
||||||
|
"mandate_access",
|
||||||
|
targetMandateId,
|
||||||
|
)
|
||||||
|
|
||||||
return UserMandateResponse(
|
return UserMandateResponse(
|
||||||
id=str(userMandate.id), # UserMandate ID as primary key
|
id=str(userMandate.id), # UserMandate ID as primary key
|
||||||
userId=data.targetUserId,
|
userId=data.targetUserId,
|
||||||
|
|
@ -697,6 +717,15 @@ def remove_user_from_mandate(
|
||||||
|
|
||||||
logger.info(f"User {context.user.id} removed user {targetUserId} from mandate {targetMandateId}")
|
logger.info(f"User {context.user.id} removed user {targetUserId} from mandate {targetMandateId}")
|
||||||
|
|
||||||
|
mname = _mandate_display_name(mandate)
|
||||||
|
create_access_change_notification(
|
||||||
|
targetUserId,
|
||||||
|
"Mandantenzugriff",
|
||||||
|
f"Sie wurden aus dem Mandanten «{mname}» entfernt.",
|
||||||
|
"mandate_access",
|
||||||
|
targetMandateId,
|
||||||
|
)
|
||||||
|
|
||||||
return {"message": "User removed from mandate", "userId": targetUserId}
|
return {"message": "User removed from mandate", "userId": targetUserId}
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
@ -792,6 +821,16 @@ def update_user_roles_in_mandate(
|
||||||
f"in mandate {targetMandateId} to {roleIds}"
|
f"in mandate {targetMandateId} to {roleIds}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
mandate_meta = rootInterface.getMandate(targetMandateId)
|
||||||
|
mname = _mandate_display_name(mandate_meta)
|
||||||
|
create_access_change_notification(
|
||||||
|
targetUserId,
|
||||||
|
"Mandantenrollen geändert",
|
||||||
|
f"Ihre Rollen im Mandanten «{mname}» wurden angepasst.",
|
||||||
|
"mandate_access",
|
||||||
|
targetMandateId,
|
||||||
|
)
|
||||||
|
|
||||||
return UserMandateResponse(
|
return UserMandateResponse(
|
||||||
id=str(membership.id), # UserMandate ID as primary key
|
id=str(membership.id), # UserMandate ID as primary key
|
||||||
userId=targetUserId,
|
userId=targetUserId,
|
||||||
|
|
@ -814,6 +853,28 @@ def update_user_roles_in_mandate(
|
||||||
# Helper Functions
|
# Helper Functions
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
def _mandate_display_name(mandate: Any) -> str:
|
||||||
|
"""Human-readable mandate label for notifications."""
|
||||||
|
if mandate is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(mandate, dict):
|
||||||
|
if mandate.get("label"):
|
||||||
|
return str(mandate["label"])
|
||||||
|
name = mandate.get("name")
|
||||||
|
if isinstance(name, dict):
|
||||||
|
return str(name.get("de") or name.get("en") or (next(iter(name.values()), "") if name else ""))
|
||||||
|
return str(name or mandate.get("id", ""))
|
||||||
|
label = getattr(mandate, "label", None)
|
||||||
|
if label:
|
||||||
|
return str(label)
|
||||||
|
name = getattr(mandate, "name", None)
|
||||||
|
if isinstance(name, dict):
|
||||||
|
return str(name.get("de") or name.get("en") or (next(iter(name.values()), "") if name else ""))
|
||||||
|
if name is not None:
|
||||||
|
return str(name)
|
||||||
|
return str(getattr(mandate, "id", ""))
|
||||||
|
|
||||||
|
|
||||||
def _getAdminMandateIds(context: RequestContext) -> List[str]:
|
def _getAdminMandateIds(context: RequestContext) -> List[str]:
|
||||||
"""
|
"""
|
||||||
Get list of mandate IDs where the user has the admin role.
|
Get list of mandate IDs where the user has the admin role.
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,31 @@ def _createNotification(
|
||||||
return notification
|
return notification
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_change_notification(
|
||||||
|
userId: str,
|
||||||
|
title: str,
|
||||||
|
message: str,
|
||||||
|
reference_type: str,
|
||||||
|
reference_id: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
In-app notification for mandate/feature access changes (triggers client nav refresh).
|
||||||
|
Failures are logged only so RBAC mutations still succeed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
_createNotification(
|
||||||
|
userId=userId,
|
||||||
|
notificationType=NotificationType.SYSTEM,
|
||||||
|
title=title,
|
||||||
|
message=message,
|
||||||
|
referenceType=reference_type,
|
||||||
|
referenceId=reference_id,
|
||||||
|
icon="shield",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not create access-change notification for user {userId}: {e}")
|
||||||
|
|
||||||
|
|
||||||
def createInvitationNotification(
|
def createInvitationNotification(
|
||||||
userId: str,
|
userId: str,
|
||||||
invitationId: str,
|
invitationId: str,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -18,7 +18,7 @@ from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM
|
||||||
from modules.auth import createAccessToken, createRefreshToken, setAccessTokenCookie, setRefreshTokenCookie, clearAccessTokenCookie, clearRefreshTokenCookie
|
from modules.auth import createAccessToken, createRefreshToken, setAccessTokenCookie, setRefreshTokenCookie, clearAccessTokenCookie, clearRefreshTokenCookie
|
||||||
from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
|
from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
|
||||||
from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority, Mandate
|
from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority, Mandate
|
||||||
from modules.datamodels.datamodelSecurity import Token
|
from modules.datamodels.datamodelSecurity import Token, TokenPurpose
|
||||||
from modules.shared.configuration import APP_CONFIG
|
from modules.shared.configuration import APP_CONFIG
|
||||||
from modules.shared.timeUtils import getUtcTimestamp
|
from modules.shared.timeUtils import getUtcTimestamp
|
||||||
|
|
||||||
|
|
@ -164,6 +164,7 @@ def login(
|
||||||
id=jti,
|
id=jti,
|
||||||
userId=user.id,
|
userId=user.id,
|
||||||
authority=AuthAuthority.LOCAL,
|
authority=AuthAuthority.LOCAL,
|
||||||
|
tokenPurpose=TokenPurpose.AUTH_SESSION,
|
||||||
tokenAccess=access_token,
|
tokenAccess=access_token,
|
||||||
tokenType="bearer",
|
tokenType="bearer",
|
||||||
expiresAt=expires_at.timestamp(),
|
expiresAt=expires_at.timestamp(),
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -110,15 +110,38 @@ def _getUserFeatureAccess(db, userId: str, instanceId: str) -> Dict[str, Any] |
|
||||||
return accesses[0] if accesses else None
|
return accesses[0] if accesses else None
|
||||||
|
|
||||||
|
|
||||||
def _findUserRole(rootInterface, instanceId: str, featureCode: str) -> str | None:
|
def _findStoreUserRoleId(
|
||||||
"""Find the user-level role for a feature instance."""
|
rootInterface,
|
||||||
|
catalogService,
|
||||||
|
instanceId: str,
|
||||||
|
featureCode: str,
|
||||||
|
) -> str | None:
|
||||||
|
"""
|
||||||
|
Resolve the feature's primary *user* role on this instance (e.g. workspace-user).
|
||||||
|
Uses catalog template labels first, then a safe fallback on instance roles.
|
||||||
|
"""
|
||||||
instanceRoles = rootInterface.getRolesByFeatureInstance(instanceId)
|
instanceRoles = rootInterface.getRolesByFeatureInstance(instanceId)
|
||||||
userRoleLabel = f"{featureCode}-user"
|
labelToId = {r.roleLabel: str(r.id) for r in instanceRoles if r.roleLabel}
|
||||||
|
|
||||||
|
preferred = f"{featureCode}-user"
|
||||||
|
if preferred in labelToId:
|
||||||
|
return labelToId[preferred]
|
||||||
|
|
||||||
|
for tpl in catalogService.getTemplateRoles(featureCode):
|
||||||
|
lbl = (tpl.get("roleLabel") or "").strip()
|
||||||
|
if not lbl:
|
||||||
|
continue
|
||||||
|
low = lbl.lower()
|
||||||
|
if "admin" in low:
|
||||||
|
continue
|
||||||
|
if lbl.endswith("-user") and lbl in labelToId:
|
||||||
|
return labelToId[lbl]
|
||||||
|
|
||||||
for role in instanceRoles:
|
for role in instanceRoles:
|
||||||
if role.roleLabel == userRoleLabel:
|
low = (role.roleLabel or "").lower()
|
||||||
return str(role.id)
|
if "admin" in low:
|
||||||
for role in instanceRoles:
|
continue
|
||||||
if "user" in role.roleLabel.lower() and "admin" not in role.roleLabel.lower():
|
if "user" in low:
|
||||||
return str(role.id)
|
return str(role.id)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -249,13 +272,25 @@ def activateStoreFeature(
|
||||||
createdAccess = db.recordCreate(FeatureAccess, featureAccess.model_dump())
|
createdAccess = db.recordCreate(FeatureAccess, featureAccess.model_dump())
|
||||||
featureAccessId = createdAccess.get("id")
|
featureAccessId = createdAccess.get("id")
|
||||||
|
|
||||||
userRoleId = _findUserRole(rootInterface, instanceId, featureCode)
|
userRoleId = _findStoreUserRoleId(rootInterface, catalogService, instanceId, featureCode)
|
||||||
if userRoleId:
|
if not userRoleId:
|
||||||
featureAccessRole = FeatureAccessRole(
|
db.recordDelete(FeatureAccess, featureAccessId)
|
||||||
featureAccessId=featureAccessId,
|
logger.error(
|
||||||
roleId=userRoleId
|
f"Store activate rollback: no user role on instance {instanceId} for feature '{featureCode}'"
|
||||||
)
|
)
|
||||||
db.recordCreate(FeatureAccessRole, featureAccessRole.model_dump())
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=(
|
||||||
|
f"No '{featureCode}-user' (or equivalent) role found on the shared instance; "
|
||||||
|
"cannot grant store access. Contact an administrator."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
featureAccessRole = FeatureAccessRole(
|
||||||
|
featureAccessId=featureAccessId,
|
||||||
|
roleId=userRoleId
|
||||||
|
)
|
||||||
|
db.recordCreate(FeatureAccessRole, featureAccessRole.model_dump())
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"User {userId} activated store feature '{featureCode}' "
|
f"User {userId} activated store feature '{featureCode}' "
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ Core service - not requested by features directly.
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional, Callable, Any
|
from typing import Optional, Callable, Any
|
||||||
|
|
||||||
from modules.datamodels.datamodelSecurity import Token
|
from modules.datamodels.datamodelSecurity import Token, TokenPurpose
|
||||||
from modules.auth import TokenManager
|
from modules.auth import TokenManager
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -34,6 +34,16 @@ class SecurityService:
|
||||||
token = self._interfaceDbApp.getConnectionToken(connectionId)
|
token = self._interfaceDbApp.getConnectionToken(connectionId)
|
||||||
if not token:
|
if not token:
|
||||||
return None
|
return None
|
||||||
|
_tp = (
|
||||||
|
token.tokenPurpose.value
|
||||||
|
if isinstance(token.tokenPurpose, TokenPurpose)
|
||||||
|
else token.tokenPurpose
|
||||||
|
)
|
||||||
|
if _tp != TokenPurpose.DATA_CONNECTION.value:
|
||||||
|
logger.warning(
|
||||||
|
f"getFreshToken: connection {connectionId} tokenPurpose is {_tp}, expected dataConnection"
|
||||||
|
)
|
||||||
|
return None
|
||||||
return self._tokenManager.ensureFreshToken(
|
return self._tokenManager.ensureFreshToken(
|
||||||
token,
|
token,
|
||||||
secondsBeforeExpiry=secondsBeforeExpiry,
|
secondsBeforeExpiry=secondsBeforeExpiry,
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@ from modules.serviceCenter.services.serviceAgent.conversationManager import (
|
||||||
)
|
)
|
||||||
from modules.shared.timeUtils import getUtcTimestamp
|
from modules.shared.timeUtils import getUtcTimestamp
|
||||||
from modules.shared.jsonUtils import closeJsonStructures
|
from modules.shared.jsonUtils import closeJsonStructures
|
||||||
|
from modules.serviceCenter.services.serviceBilling.mainServiceBilling import (
|
||||||
|
InsufficientBalanceException,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -175,6 +178,18 @@ async def runAgentLoop(
|
||||||
else:
|
else:
|
||||||
aiResponse = await aiCallFn(aiRequest)
|
aiResponse = await aiCallFn(aiRequest)
|
||||||
|
|
||||||
|
except InsufficientBalanceException as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Insufficient balance in round {state.currentRound} (mandate={mandateId}): {e.message}"
|
||||||
|
)
|
||||||
|
state.status = AgentStatusEnum.ERROR
|
||||||
|
state.abortReason = e.message
|
||||||
|
yield AgentEvent(
|
||||||
|
type=AgentEventTypeEnum.ERROR,
|
||||||
|
content=e.message,
|
||||||
|
data=e.toClientDict(),
|
||||||
|
)
|
||||||
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"AI call failed in round {state.currentRound}: {e}", exc_info=True)
|
logger.error(f"AI call failed in round {state.currentRound}: {e}", exc_info=True)
|
||||||
state.status = AgentStatusEnum.ERROR
|
state.status = AgentStatusEnum.ERROR
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,10 @@ from modules.shared.jsonUtils import (
|
||||||
)
|
)
|
||||||
from .subJsonResponseHandling import JsonResponseHandler
|
from .subJsonResponseHandling import JsonResponseHandler
|
||||||
from modules.datamodels.datamodelAi import JsonAccumulationState
|
from modules.datamodels.datamodelAi import JsonAccumulationState
|
||||||
|
from modules.datamodels.datamodelBilling import BillingModelEnum
|
||||||
|
from modules.serviceCenter.services.serviceBilling.billingExhaustedNotify import (
|
||||||
|
maybeEmailMandatePoolExhausted,
|
||||||
|
)
|
||||||
from modules.serviceCenter.services.serviceBilling.mainServiceBilling import (
|
from modules.serviceCenter.services.serviceBilling.mainServiceBilling import (
|
||||||
getService as getBillingService,
|
getService as getBillingService,
|
||||||
InsufficientBalanceException,
|
InsufficientBalanceException,
|
||||||
|
|
@ -592,10 +596,19 @@ detectedIntent-Werte:
|
||||||
f"Balance {balance_str} CHF, "
|
f"Balance {balance_str} CHF, "
|
||||||
f"Reason: {balanceCheck.reason}"
|
f"Reason: {balanceCheck.reason}"
|
||||||
)
|
)
|
||||||
raise InsufficientBalanceException(
|
if balanceCheck.billingModel == BillingModelEnum.PREPAY_MANDATE:
|
||||||
currentBalance=balanceCheck.currentBalance or 0.0,
|
ulabel = (getattr(user, "email", None) or getattr(user, "username", None) or str(user.id))
|
||||||
requiredAmount=estimatedCost,
|
maybeEmailMandatePoolExhausted(
|
||||||
message=f"Ungenugendes Guthaben. Aktuell: CHF {balance_str}"
|
str(mandateId),
|
||||||
|
str(user.id),
|
||||||
|
str(ulabel),
|
||||||
|
float(balanceCheck.currentBalance or 0.0),
|
||||||
|
float(estimatedCost),
|
||||||
|
)
|
||||||
|
raise InsufficientBalanceException.fromBalanceCheck(
|
||||||
|
balanceCheck,
|
||||||
|
str(mandateId),
|
||||||
|
float(estimatedCost),
|
||||||
)
|
)
|
||||||
|
|
||||||
balance_str = f"{(balanceCheck.currentBalance or 0):.2f}"
|
balance_str = f"{(balanceCheck.currentBalance or 0):.2f}"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""
|
||||||
|
When the shared mandate pool (PREPAY_MANDATE) is exhausted, notify billing contacts.
|
||||||
|
|
||||||
|
Recipients: BillingSettings.notifyEmails for the mandate (configure as mandate owner / finance).
|
||||||
|
Emails are throttled per mandate to avoid spam (one notification per cooldown window).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from modules.datamodels.datamodelMessaging import MessagingChannel
|
||||||
|
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
|
||||||
|
from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface
|
||||||
|
from modules.security.rootAccess import getRootUser
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# mandate_id -> unix timestamp of last pool-exhausted notification email
|
||||||
|
_poolExhaustedEmailLastSent: Dict[str, float] = {}
|
||||||
|
_DEFAULT_COOLDOWN_SEC = 3600
|
||||||
|
|
||||||
|
|
||||||
|
def _normalizeNotifyEmails(raw: Any) -> List[str]:
|
||||||
|
if raw is None:
|
||||||
|
return []
|
||||||
|
if isinstance(raw, list):
|
||||||
|
return [str(e).strip() for e in raw if str(e).strip()]
|
||||||
|
if isinstance(raw, str):
|
||||||
|
s = raw.strip()
|
||||||
|
if not s:
|
||||||
|
return []
|
||||||
|
# JSON array string
|
||||||
|
if s.startswith("["):
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
|
||||||
|
parsed = json.loads(s)
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
return [str(e).strip() for e in parsed if str(e).strip()]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return [s]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def maybeEmailMandatePoolExhausted(
|
||||||
|
mandateId: str,
|
||||||
|
triggeringUserId: str,
|
||||||
|
triggeringUserLabel: str,
|
||||||
|
currentBalance: float,
|
||||||
|
requiredAmount: float,
|
||||||
|
cooldownSec: float = _DEFAULT_COOLDOWN_SEC,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Send one email per mandate per cooldown to BillingSettings.notifyEmails.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mandateId: Mandate whose pool is empty.
|
||||||
|
triggeringUserId: User who hit the block.
|
||||||
|
triggeringUserLabel: Display (e.g. email or username).
|
||||||
|
currentBalance: Pool balance (CHF).
|
||||||
|
requiredAmount: Minimum required (CHF).
|
||||||
|
cooldownSec: Minimum seconds between emails for this mandate.
|
||||||
|
"""
|
||||||
|
if not mandateId:
|
||||||
|
return
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
last = _poolExhaustedEmailLastSent.get(mandateId, 0.0)
|
||||||
|
if last and (now - last) < cooldownSec:
|
||||||
|
logger.debug(
|
||||||
|
"Skip mandate pool exhausted email (cooldown): mandate=%s last=%.0fs ago",
|
||||||
|
mandateId,
|
||||||
|
now - last,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
billing = getBillingInterface(getRootUser(), mandateId)
|
||||||
|
settings = billing.getSettings(mandateId) or {}
|
||||||
|
recipients = _normalizeNotifyEmails(settings.get("notifyEmails"))
|
||||||
|
if not recipients:
|
||||||
|
logger.warning(
|
||||||
|
"PREPAY_MANDATE pool exhausted for mandate %s but notifyEmails is empty — "
|
||||||
|
"configure BillingSettings.notifyEmails for owner alerts",
|
||||||
|
mandateId,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
subject = f"[PowerOn] Mandanten-Budget aufgebraucht (Mandant {mandateId[:8]}…)"
|
||||||
|
body = (
|
||||||
|
f"Das gemeinsame Guthaben (PREPAY_MANDATE) für diesen Mandanten ist nicht mehr ausreichend.\n\n"
|
||||||
|
f"Mandanten-ID: {mandateId}\n"
|
||||||
|
f"Aktuelles Guthaben (Pool): CHF {currentBalance:.2f}\n"
|
||||||
|
f"Benötigt (mind.): CHF {requiredAmount:.2f}\n\n"
|
||||||
|
f"Auslösende/r Benutzer/in: {triggeringUserLabel} (ID: {triggeringUserId})\n\n"
|
||||||
|
f"Bitte laden Sie das Mandats-Guthaben in der Billing-Verwaltung auf, "
|
||||||
|
f"damit Benutzer wieder AI-Funktionen nutzen können.\n"
|
||||||
|
)
|
||||||
|
escaped = html.escape(body)
|
||||||
|
htmlMessage = f"""<!DOCTYPE html>
|
||||||
|
<html><head><meta charset="utf-8"></head>
|
||||||
|
<body style="font-family: Arial, sans-serif; line-height: 1.6;">
|
||||||
|
{escaped.replace(chr(10), '<br>\n')}
|
||||||
|
</body></html>"""
|
||||||
|
|
||||||
|
messaging = getMessagingInterface()
|
||||||
|
any_ok = False
|
||||||
|
for to in recipients:
|
||||||
|
try:
|
||||||
|
ok = messaging.send(
|
||||||
|
channel=MessagingChannel.EMAIL,
|
||||||
|
recipient=to,
|
||||||
|
subject=subject,
|
||||||
|
message=htmlMessage,
|
||||||
|
)
|
||||||
|
if ok:
|
||||||
|
any_ok = True
|
||||||
|
else:
|
||||||
|
logger.warning("Pool exhausted email failed for %s", to)
|
||||||
|
except Exception as send_err:
|
||||||
|
logger.error("Error sending pool exhausted email to %s: %s", to, send_err)
|
||||||
|
|
||||||
|
if any_ok:
|
||||||
|
_poolExhaustedEmailLastSent[mandateId] = now
|
||||||
|
logger.info(
|
||||||
|
"Sent mandate pool exhausted notification for mandate %s to %s recipient(s)",
|
||||||
|
mandateId,
|
||||||
|
len(recipients),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("maybeEmailMandatePoolExhausted failed: %s", e, exc_info=True)
|
||||||
|
|
@ -22,6 +22,7 @@ from modules.datamodels.datamodelBilling import (
|
||||||
ReferenceTypeEnum,
|
ReferenceTypeEnum,
|
||||||
BillingTransaction,
|
BillingTransaction,
|
||||||
BillingBalanceResponse,
|
BillingBalanceResponse,
|
||||||
|
parseBillingModelFromStoredValue,
|
||||||
)
|
)
|
||||||
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
|
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
|
||||||
|
|
||||||
|
|
@ -333,7 +334,7 @@ class BillingService:
|
||||||
logger.warning(f"No billing settings for mandate {self.mandateId}")
|
logger.warning(f"No billing settings for mandate {self.mandateId}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value))
|
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
|
||||||
|
|
||||||
# Get or create account
|
# Get or create account
|
||||||
if billingModel == BillingModelEnum.PREPAY_USER:
|
if billingModel == BillingModelEnum.PREPAY_USER:
|
||||||
|
|
@ -389,15 +390,127 @@ class BillingService:
|
||||||
# Exception Classes
|
# Exception Classes
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
class InsufficientBalanceException(Exception):
|
BILLING_USER_ACTION_TOP_UP_SELF = "TOP_UP_SELF"
|
||||||
"""Raised when there's insufficient balance for an operation."""
|
BILLING_USER_ACTION_CONTACT_MANDATE_ADMIN = "CONTACT_MANDATE_ADMIN"
|
||||||
|
|
||||||
def __init__(self, currentBalance: float, requiredAmount: float, message: str = None):
|
|
||||||
self.currentBalance = currentBalance
|
def _userActionForBillingModel(bm: BillingModelEnum) -> str:
|
||||||
self.requiredAmount = requiredAmount
|
if bm == BillingModelEnum.PREPAY_USER:
|
||||||
self.message = message or f"Insufficient balance. Current: {currentBalance:.2f} CHF, Required: {requiredAmount:.2f} CHF"
|
return BILLING_USER_ACTION_TOP_UP_SELF
|
||||||
|
return BILLING_USER_ACTION_CONTACT_MANDATE_ADMIN
|
||||||
|
|
||||||
|
|
||||||
|
def _buildInsufficientBalanceMessages(
|
||||||
|
bm: BillingModelEnum,
|
||||||
|
currentBalance: float,
|
||||||
|
requiredAmount: float,
|
||||||
|
) -> tuple:
|
||||||
|
bal_s = f"{currentBalance:.2f}"
|
||||||
|
req_s = f"{requiredAmount:.2f}"
|
||||||
|
if bm == BillingModelEnum.PREPAY_USER:
|
||||||
|
msg_de = (
|
||||||
|
f"Ihr persönliches Guthaben ist aufgebraucht (aktuell CHF {bal_s}, benötigt mindestens CHF {req_s}). "
|
||||||
|
"Bitte laden Sie unter „Billing“ Guthaben nach."
|
||||||
|
)
|
||||||
|
msg_en = (
|
||||||
|
f"Your personal balance is exhausted (current CHF {bal_s}, at least CHF {req_s} required). "
|
||||||
|
"Please top up under Billing."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
msg_de = (
|
||||||
|
f"Das Mandanten-Budget ist aufgebraucht (aktuell CHF {bal_s}, benötigt mindestens CHF {req_s}). "
|
||||||
|
"Bitte informieren Sie die Administratorin bzw. den Administrator Ihres Mandanten. "
|
||||||
|
"Die in den Billing-Einstellungen hinterlegten Kontakte wurden per E-Mail informiert (falls konfiguriert)."
|
||||||
|
)
|
||||||
|
msg_en = (
|
||||||
|
f"The organization budget is exhausted (current CHF {bal_s}, at least CHF {req_s} required). "
|
||||||
|
"Please contact your mandate administrator. Billing notification contacts were emailed if configured."
|
||||||
|
)
|
||||||
|
return msg_de, msg_en
|
||||||
|
|
||||||
|
|
||||||
|
class InsufficientBalanceException(Exception):
|
||||||
|
"""Raised when there's insufficient balance for an operation.
|
||||||
|
|
||||||
|
Carries structured fields for API/SSE clients (userAction, billingModel, localized hints).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
currentBalance: float,
|
||||||
|
requiredAmount: float,
|
||||||
|
message: Optional[str] = None,
|
||||||
|
*,
|
||||||
|
billing_model: Optional[BillingModelEnum] = None,
|
||||||
|
mandate_id: str = "",
|
||||||
|
user_action: Optional[str] = None,
|
||||||
|
message_de: Optional[str] = None,
|
||||||
|
message_en: Optional[str] = None,
|
||||||
|
):
|
||||||
|
self.currentBalance = float(currentBalance)
|
||||||
|
self.requiredAmount = float(requiredAmount)
|
||||||
|
self.billing_model = billing_model
|
||||||
|
self.mandate_id = mandate_id or ""
|
||||||
|
if billing_model is not None:
|
||||||
|
self.user_action = user_action or _userActionForBillingModel(billing_model)
|
||||||
|
else:
|
||||||
|
self.user_action = user_action or BILLING_USER_ACTION_TOP_UP_SELF
|
||||||
|
|
||||||
|
if message_de is not None and message_en is not None:
|
||||||
|
self.message_de = message_de
|
||||||
|
self.message_en = message_en
|
||||||
|
self.message = message or message_de
|
||||||
|
elif message:
|
||||||
|
self.message = message
|
||||||
|
self.message_de = message
|
||||||
|
self.message_en = message
|
||||||
|
else:
|
||||||
|
bm = billing_model or BillingModelEnum.PREPAY_USER
|
||||||
|
md, me = _buildInsufficientBalanceMessages(bm, self.currentBalance, self.requiredAmount)
|
||||||
|
self.message_de = md
|
||||||
|
self.message_en = me
|
||||||
|
self.message = md
|
||||||
super().__init__(self.message)
|
super().__init__(self.message)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def fromBalanceCheck(
|
||||||
|
cls,
|
||||||
|
check: BillingCheckResult,
|
||||||
|
mandate_id: str,
|
||||||
|
required_amount: float,
|
||||||
|
) -> "InsufficientBalanceException":
|
||||||
|
bm = check.billingModel or BillingModelEnum.PREPAY_MANDATE
|
||||||
|
bal = float(check.currentBalance or 0.0)
|
||||||
|
msg_de, msg_en = _buildInsufficientBalanceMessages(bm, bal, required_amount)
|
||||||
|
return cls(
|
||||||
|
bal,
|
||||||
|
required_amount,
|
||||||
|
message=msg_de,
|
||||||
|
billing_model=bm,
|
||||||
|
mandate_id=mandate_id or "",
|
||||||
|
message_de=msg_de,
|
||||||
|
message_en=msg_en,
|
||||||
|
)
|
||||||
|
|
||||||
|
def toClientDict(self) -> Dict[str, Any]:
|
||||||
|
"""Structured payload for HTTP 402, SSE item, or JSON error details."""
|
||||||
|
out: Dict[str, Any] = {
|
||||||
|
"error": "INSUFFICIENT_BALANCE",
|
||||||
|
"currentBalance": round(self.currentBalance, 4),
|
||||||
|
"requiredAmount": round(self.requiredAmount, 4),
|
||||||
|
"message": self.message,
|
||||||
|
"messageDe": self.message_de,
|
||||||
|
"messageEn": self.message_en,
|
||||||
|
"userAction": self.user_action,
|
||||||
|
}
|
||||||
|
if self.billing_model is not None:
|
||||||
|
out["billingModel"] = self.billing_model.value
|
||||||
|
if self.mandate_id:
|
||||||
|
out["mandateId"] = self.mandate_id
|
||||||
|
if self.user_action == BILLING_USER_ACTION_TOP_UP_SELF:
|
||||||
|
out["billingUiPath"] = "/billing"
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
class ProviderNotAllowedException(Exception):
|
class ProviderNotAllowedException(Exception):
|
||||||
"""Raised when a user doesn't have permission to use an AI provider."""
|
"""Raised when a user doesn't have permission to use an AI provider."""
|
||||||
|
|
|
||||||
|
|
@ -447,6 +447,11 @@ RESOURCE_OBJECTS = [
|
||||||
"label": {"en": "Store: AI Workspace", "de": "Store: AI Workspace", "fr": "Store: AI Workspace"},
|
"label": {"en": "Store: AI Workspace", "de": "Store: AI Workspace", "fr": "Store: AI Workspace"},
|
||||||
"meta": {"category": "store", "featureCode": "workspace"}
|
"meta": {"category": "store", "featureCode": "workspace"}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"objectKey": "resource.store.commcoach",
|
||||||
|
"label": {"en": "Store: CommCoach", "de": "Store: CommCoach", "fr": "Store: CommCoach"},
|
||||||
|
"meta": {"category": "store", "featureCode": "commcoach"}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"objectKey": "resource.system.api.auth",
|
"objectKey": "resource.system.api.auth",
|
||||||
"label": {"en": "Authentication API", "de": "Authentifizierungs-API", "fr": "API d'authentification"},
|
"label": {"en": "Authentication API", "de": "Authentifizierungs-API", "fr": "API d'authentification"},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue