Merge pull request #115 from valueonag/feat/unified-data-bar
Feat/unified data bar
This commit is contained in:
commit
a1df2df3c5
115 changed files with 6850 additions and 2958 deletions
10
app.py
10
app.py
|
|
@ -383,7 +383,7 @@ async def lifespan(app: FastAPI):
|
||||||
if settingsCreated > 0:
|
if settingsCreated > 0:
|
||||||
logger.info(f"Billing startup: Created {settingsCreated} missing mandate billing settings")
|
logger.info(f"Billing startup: Created {settingsCreated} missing mandate billing settings")
|
||||||
|
|
||||||
# Step 2: Ensure all users have billing accounts (for PREPAY_USER mandates)
|
# Step 2: Ensure all users have billing audit accounts
|
||||||
accountsCreated = billingInterface.ensureAllUserAccountsExist()
|
accountsCreated = billingInterface.ensureAllUserAccountsExist()
|
||||||
if accountsCreated > 0:
|
if accountsCreated > 0:
|
||||||
logger.info(f"Billing startup: Created {accountsCreated} missing user accounts")
|
logger.info(f"Billing startup: Created {accountsCreated} missing user accounts")
|
||||||
|
|
@ -510,7 +510,7 @@ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||||
|
|
||||||
|
|
||||||
async def _insufficientBalanceHandler(request: Request, exc: Exception):
|
async def _insufficientBalanceHandler(request: Request, exc: Exception):
|
||||||
"""HTTP 402 with structured billing hint (PREPAY_USER vs PREPAY_MANDATE)."""
|
"""HTTP 402 with structured billing hint."""
|
||||||
payload = exc.toClientDict() if hasattr(exc, "toClientDict") else {"error": "INSUFFICIENT_BALANCE", "message": str(exc)}
|
payload = exc.toClientDict() if hasattr(exc, "toClientDict") else {"error": "INSUFFICIENT_BALANCE", "message": str(exc)}
|
||||||
return JSONResponse(status_code=402, content={"detail": payload})
|
return JSONResponse(status_code=402, content={"detail": payload})
|
||||||
|
|
||||||
|
|
@ -555,6 +555,9 @@ app.include_router(userRouter)
|
||||||
from modules.routes.routeDataFiles import router as fileRouter
|
from modules.routes.routeDataFiles import router as fileRouter
|
||||||
app.include_router(fileRouter)
|
app.include_router(fileRouter)
|
||||||
|
|
||||||
|
from modules.routes.routeDataSources import router as dataSourceRouter
|
||||||
|
app.include_router(dataSourceRouter)
|
||||||
|
|
||||||
from modules.routes.routeDataPrompts import router as promptRouter
|
from modules.routes.routeDataPrompts import router as promptRouter
|
||||||
app.include_router(promptRouter)
|
app.include_router(promptRouter)
|
||||||
|
|
||||||
|
|
@ -579,6 +582,9 @@ app.include_router(clickupApiRouter)
|
||||||
from modules.routes.routeVoiceGoogle import router as voiceGoogleRouter
|
from modules.routes.routeVoiceGoogle import router as voiceGoogleRouter
|
||||||
app.include_router(voiceGoogleRouter)
|
app.include_router(voiceGoogleRouter)
|
||||||
|
|
||||||
|
from modules.routes.routeVoiceUser import router as voiceUserRouter
|
||||||
|
app.include_router(voiceUserRouter)
|
||||||
|
|
||||||
from modules.routes.routeSecurityAdmin import router as adminSecurityRouter
|
from modules.routes.routeSecurityAdmin import router as adminSecurityRouter
|
||||||
app.include_router(adminSecurityRouter)
|
app.include_router(adminSecurityRouter)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,9 @@ from typing import List, Dict, Any, Optional, AsyncGenerator, Union
|
||||||
from modules.datamodels.datamodelAi import AiModel, AiModelCall, AiModelResponse
|
from modules.datamodels.datamodelAi import AiModel, AiModelCall, AiModelResponse
|
||||||
|
|
||||||
|
|
||||||
_RETRY_AFTER_PATTERN = _re.compile(r"try again in (\d+(?:\.\d+)?)\s*s", _re.IGNORECASE)
|
_RETRY_AFTER_PATTERN = _re.compile(
|
||||||
|
r"(?:try again in|retry after)\s+(\d+(?:\.\d+)?)\s*s", _re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _parseRetryAfterSeconds(message: str) -> float:
|
def _parseRetryAfterSeconds(message: str) -> float:
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,9 @@ Connects to the private-llm service running on-premise with Ollama backend.
|
||||||
Provides OCR and Vision capabilities via local AI models.
|
Provides OCR and Vision capabilities via local AI models.
|
||||||
|
|
||||||
Models:
|
Models:
|
||||||
- poweron-ocr-general: Text extraction and OCR (deepseek backend)
|
- poweron-text-general: Text (qwen2.5); NEUTRALIZATION_TEXT + data/plan ops
|
||||||
- poweron-vision-general: General vision tasks (qwen2.5vl backend)
|
- poweron-vision-general: Vision (qwen2.5vl); IMAGE_ANALYSE + NEUTRALIZATION_IMAGE
|
||||||
- poweron-vision-deep: Deep vision analysis (granite3.2 backend)
|
- poweron-vision-deep: Vision (granite3.2); IMAGE_ANALYSE + NEUTRALIZATION_IMAGE
|
||||||
|
|
||||||
Pricing (CHF per call):
|
Pricing (CHF per call):
|
||||||
- Text models: CHF 0.010
|
- Text models: CHF 0.010
|
||||||
|
|
@ -22,7 +22,7 @@ import time
|
||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from modules.shared.configuration import APP_CONFIG
|
from modules.shared.configuration import APP_CONFIG
|
||||||
from .aicoreBase import BaseConnectorAi
|
from .aicoreBase import BaseConnectorAi, RateLimitExceededException
|
||||||
from modules.datamodels.datamodelAi import (
|
from modules.datamodels.datamodelAi import (
|
||||||
AiModel,
|
AiModel,
|
||||||
PriorityEnum,
|
PriorityEnum,
|
||||||
|
|
@ -245,6 +245,7 @@ class AiPrivateLlm(BaseConnectorAi):
|
||||||
(OperationTypeEnum.DATA_ANALYSE, 8),
|
(OperationTypeEnum.DATA_ANALYSE, 8),
|
||||||
(OperationTypeEnum.DATA_GENERATE, 8),
|
(OperationTypeEnum.DATA_GENERATE, 8),
|
||||||
(OperationTypeEnum.DATA_EXTRACT, 8),
|
(OperationTypeEnum.DATA_EXTRACT, 8),
|
||||||
|
(OperationTypeEnum.NEUTRALIZATION_TEXT, 9),
|
||||||
),
|
),
|
||||||
version="qwen2.5:7b",
|
version="qwen2.5:7b",
|
||||||
calculatepriceCHF=lambda processingTime, bytesSent, bytesReceived: PRICE_TEXT_PER_CALL
|
calculatepriceCHF=lambda processingTime, bytesSent, bytesReceived: PRICE_TEXT_PER_CALL
|
||||||
|
|
@ -270,6 +271,7 @@ class AiPrivateLlm(BaseConnectorAi):
|
||||||
processingMode=ProcessingModeEnum.ADVANCED,
|
processingMode=ProcessingModeEnum.ADVANCED,
|
||||||
operationTypes=createOperationTypeRatings(
|
operationTypes=createOperationTypeRatings(
|
||||||
(OperationTypeEnum.IMAGE_ANALYSE, 9),
|
(OperationTypeEnum.IMAGE_ANALYSE, 9),
|
||||||
|
(OperationTypeEnum.NEUTRALIZATION_IMAGE, 9),
|
||||||
),
|
),
|
||||||
version="qwen2.5vl:7b",
|
version="qwen2.5vl:7b",
|
||||||
calculatepriceCHF=lambda processingTime, bytesSent, bytesReceived: PRICE_VISION_PER_CALL
|
calculatepriceCHF=lambda processingTime, bytesSent, bytesReceived: PRICE_VISION_PER_CALL
|
||||||
|
|
@ -295,6 +297,7 @@ class AiPrivateLlm(BaseConnectorAi):
|
||||||
processingMode=ProcessingModeEnum.DETAILED,
|
processingMode=ProcessingModeEnum.DETAILED,
|
||||||
operationTypes=createOperationTypeRatings(
|
operationTypes=createOperationTypeRatings(
|
||||||
(OperationTypeEnum.IMAGE_ANALYSE, 9),
|
(OperationTypeEnum.IMAGE_ANALYSE, 9),
|
||||||
|
(OperationTypeEnum.NEUTRALIZATION_IMAGE, 9),
|
||||||
),
|
),
|
||||||
version="granite3.2-vision",
|
version="granite3.2-vision",
|
||||||
calculatepriceCHF=lambda processingTime, bytesSent, bytesReceived: PRICE_VISION_PER_CALL
|
calculatepriceCHF=lambda processingTime, bytesSent, bytesReceived: PRICE_VISION_PER_CALL
|
||||||
|
|
@ -367,6 +370,9 @@ class AiPrivateLlm(BaseConnectorAi):
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
errorMessage = f"Private-LLM API error: {response.status_code} - {response.text}"
|
errorMessage = f"Private-LLM API error: {response.status_code} - {response.text}"
|
||||||
|
if response.status_code == 429:
|
||||||
|
logger.warning(errorMessage)
|
||||||
|
raise RateLimitExceededException(errorMessage)
|
||||||
logger.error(errorMessage)
|
logger.error(errorMessage)
|
||||||
raise HTTPException(status_code=500, detail=errorMessage)
|
raise HTTPException(status_code=500, detail=errorMessage)
|
||||||
|
|
||||||
|
|
@ -458,6 +464,9 @@ class AiPrivateLlm(BaseConnectorAi):
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
errorMessage = f"Private-LLM API error: {response.status_code} - {response.text}"
|
errorMessage = f"Private-LLM API error: {response.status_code} - {response.text}"
|
||||||
|
if response.status_code == 429:
|
||||||
|
logger.warning(errorMessage)
|
||||||
|
raise RateLimitExceededException(errorMessage)
|
||||||
logger.error(errorMessage)
|
logger.error(errorMessage)
|
||||||
raise HTTPException(status_code=500, detail=errorMessage)
|
raise HTTPException(status_code=500, detail=errorMessage)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -181,7 +181,7 @@ class TokenManager:
|
||||||
# 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:
|
||||||
nowTs = getUtcTimestamp()
|
nowTs = getUtcTimestamp()
|
||||||
createdTs = parseTimestamp(oldToken.createdAt, default=0.0)
|
createdTs = parseTimestamp(oldToken.sysCreatedAt, default=0.0)
|
||||||
secondsSinceLastRefresh = nowTs - createdTs
|
secondsSinceLastRefresh = nowTs - createdTs
|
||||||
if secondsSinceLastRefresh < 10 * 60:
|
if secondsSinceLastRefresh < 10 * 60:
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,14 @@ import re
|
||||||
import psycopg2
|
import psycopg2
|
||||||
import psycopg2.extras
|
import psycopg2.extras
|
||||||
import logging
|
import logging
|
||||||
from typing import List, Dict, Any, Optional, Union, get_origin, get_args, Type
|
from typing import List, Dict, Any, Optional, Union, get_origin, get_args, Type, Set, Tuple
|
||||||
import uuid
|
import uuid
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
from modules.shared.timeUtils import getUtcTimestamp
|
from modules.shared.timeUtils import getUtcTimestamp
|
||||||
from modules.shared.configuration import APP_CONFIG
|
from modules.shared.configuration import APP_CONFIG
|
||||||
|
from modules.datamodels.datamodelBase import PowerOnModel
|
||||||
from modules.datamodels.datamodelUam import User, AccessLevel, UserPermissions
|
from modules.datamodels.datamodelUam import User, AccessLevel, UserPermissions
|
||||||
from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext
|
from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext
|
||||||
|
|
||||||
|
|
@ -20,7 +21,7 @@ logger = logging.getLogger(__name__)
|
||||||
# No mapping needed - table name = Pydantic model name exactly
|
# No mapping needed - table name = Pydantic model name exactly
|
||||||
|
|
||||||
|
|
||||||
class SystemTable(BaseModel):
|
class SystemTable(PowerOnModel):
|
||||||
"""Data model for system table entries"""
|
"""Data model for system table entries"""
|
||||||
|
|
||||||
table_name: str = Field(
|
table_name: str = Field(
|
||||||
|
|
@ -157,6 +158,88 @@ def _parseRecordFields(record: Dict[str, Any], fields: Dict[str, str], context:
|
||||||
logger.warning(f"Could not parse JSONB field {fieldName}, keeping as string ({context})")
|
logger.warning(f"Could not parse JSONB field {fieldName}, keeping as string ({context})")
|
||||||
|
|
||||||
|
|
||||||
|
# Legacy column names (historical _* internal names and old camelCase audit fields) -> PowerOn sys* columns.
|
||||||
|
# Order matters: more specific / underscore names first; first successful copy wins per cell via IS NULL on target.
|
||||||
|
_LEGACY_FIELD_TO_SYS: Tuple[Tuple[str, str], ...] = (
|
||||||
|
("_createdAt", "sysCreatedAt"),
|
||||||
|
("_createdBy", "sysCreatedBy"),
|
||||||
|
("_modifiedAt", "sysModifiedAt"),
|
||||||
|
("_modifiedBy", "sysModifiedBy"),
|
||||||
|
("createdAt", "sysCreatedAt"),
|
||||||
|
("creationDate", "sysCreatedAt"),
|
||||||
|
("updatedAt", "sysModifiedAt"),
|
||||||
|
("lastModified", "sysModifiedAt"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _quotePgIdent(name: str) -> str:
|
||||||
|
return '"' + str(name).replace('"', '""') + '"'
|
||||||
|
|
||||||
|
|
||||||
|
def _resolveColumnCaseInsensitive(cols: Set[str], logicalName: str) -> Optional[str]:
|
||||||
|
"""Match information_schema column_name to logical CamelCase (PG folds unquoted legacy names to lowercase)."""
|
||||||
|
if not logicalName or not cols:
|
||||||
|
return None
|
||||||
|
for c in cols:
|
||||||
|
if c.lower() == logicalName.lower():
|
||||||
|
return c
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _pgColumnDataType(cursor, tablePg: str, colPg: str) -> Optional[str]:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT data_type FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = %s AND column_name = %s
|
||||||
|
""",
|
||||||
|
(tablePg, colPg),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return row["data_type"] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def _legacySourceToSysSqlExpr(srcIdent: str, srcType: Optional[str], tgtType: Optional[str]) -> str:
|
||||||
|
"""Build RHS for UPDATE sys* = expr from legacy _* column (handles text/timestamp -> double precision)."""
|
||||||
|
s = _quotePgIdent(srcIdent)
|
||||||
|
sl = (srcType or "").lower()
|
||||||
|
tl = (tgtType or "").lower()
|
||||||
|
if "double" in tl or tl == "real" or tl == "numeric":
|
||||||
|
if any(x in sl for x in ("double precision", "real", "numeric", "integer", "bigint", "smallint")):
|
||||||
|
return f"{s}::double precision"
|
||||||
|
if "timestamp" in sl or sl == "date":
|
||||||
|
return f"EXTRACT(EPOCH FROM {s}::timestamptz)"
|
||||||
|
if "text" in sl or "character" in sl or sl == "uuid":
|
||||||
|
return (
|
||||||
|
f"CASE WHEN trim({s}::text) ~ '^[+-]?[0-9]+(\\.[0-9]*)?([eE][+-]?[0-9]+)?$' "
|
||||||
|
f"THEN trim({s}::text)::double precision "
|
||||||
|
f"ELSE EXTRACT(EPOCH FROM trim({s}::text)::timestamptz) END"
|
||||||
|
)
|
||||||
|
return s
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def _listPublicBaseTableNames(cursor) -> List[str]:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT table_name FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
|
||||||
|
ORDER BY table_name
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return [row["table_name"] for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def _listTableColumnNames(cursor, tableName: str) -> Set[str]:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = %s
|
||||||
|
""",
|
||||||
|
(tableName,),
|
||||||
|
)
|
||||||
|
return {row["column_name"] for row in cursor.fetchall()}
|
||||||
|
|
||||||
|
|
||||||
# Cache connectors by (host, database, port) to avoid duplicate inits for same database.
|
# Cache connectors by (host, database, port) to avoid duplicate inits for same database.
|
||||||
# Thread safety: _connector_cache_lock protects cache access. userId is request-scoped via
|
# Thread safety: _connector_cache_lock protects cache access. userId is request-scoped via
|
||||||
# contextvars to avoid races when concurrent requests share the same connector.
|
# contextvars to avoid races when concurrent requests share the same connector.
|
||||||
|
|
@ -178,7 +261,7 @@ def _get_cached_connector(
|
||||||
userId: str = None,
|
userId: str = None,
|
||||||
) -> "DatabaseConnector":
|
) -> "DatabaseConnector":
|
||||||
"""Return cached DatabaseConnector for same (host, database, port) to avoid duplicate PostgreSQL inits.
|
"""Return cached DatabaseConnector for same (host, database, port) to avoid duplicate PostgreSQL inits.
|
||||||
Uses contextvars for userId so concurrent requests sharing the same connector get correct _createdBy/_modifiedBy.
|
Uses contextvars for userId so concurrent requests sharing the same connector get correct sysCreatedBy/sysModifiedBy.
|
||||||
"""
|
"""
|
||||||
port = int(dbPort) if dbPort is not None else 5432
|
port = int(dbPort) if dbPort is not None else 5432
|
||||||
key = (dbHost, dbDatabase, port)
|
key = (dbHost, dbDatabase, port)
|
||||||
|
|
@ -327,8 +410,10 @@ class DatabaseConnector:
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
table_name VARCHAR(255) UNIQUE NOT NULL,
|
table_name VARCHAR(255) UNIQUE NOT NULL,
|
||||||
initial_id VARCHAR(255) NOT NULL,
|
initial_id VARCHAR(255) NOT NULL,
|
||||||
_createdAt DOUBLE PRECISION,
|
"sysCreatedAt" DOUBLE PRECISION,
|
||||||
_modifiedAt DOUBLE PRECISION
|
"sysCreatedBy" VARCHAR(255),
|
||||||
|
"sysModifiedAt" DOUBLE PRECISION,
|
||||||
|
"sysModifiedBy" VARCHAR(255)
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
@ -371,6 +456,63 @@ class DatabaseConnector:
|
||||||
logger.warning(f"Connection lost, reconnecting: {e}")
|
logger.warning(f"Connection lost, reconnecting: {e}")
|
||||||
self._connect()
|
self._connect()
|
||||||
|
|
||||||
|
def migrateLegacyUnderscoreSysColumns(self) -> int:
|
||||||
|
"""
|
||||||
|
Scan all public base tables on this connection's database. Where both a legacy
|
||||||
|
source column (any case: _createdAt, createdAt, creationDate, …) and the matching
|
||||||
|
sys* column exist, UPDATE sys* from legacy where sys* IS NULL AND legacy IS NOT NULL.
|
||||||
|
Idempotent; run after schema adds sys* columns (see _ensureTableExists).
|
||||||
|
"""
|
||||||
|
self._ensure_connection()
|
||||||
|
total = 0
|
||||||
|
try:
|
||||||
|
with self.connection.cursor() as cursor:
|
||||||
|
tableNames = _listPublicBaseTableNames(cursor)
|
||||||
|
for table in tableNames:
|
||||||
|
with self.connection.cursor() as cursor:
|
||||||
|
cols = _listTableColumnNames(cursor, table)
|
||||||
|
for legacyLogical, sysLogical in _LEGACY_FIELD_TO_SYS:
|
||||||
|
src = _resolveColumnCaseInsensitive(cols, legacyLogical)
|
||||||
|
tgt = _resolveColumnCaseInsensitive(cols, sysLogical)
|
||||||
|
if not src or not tgt or src == tgt:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with self.connection.cursor() as cursor:
|
||||||
|
srcType = _pgColumnDataType(cursor, table, src)
|
||||||
|
tgtType = _pgColumnDataType(cursor, table, tgt)
|
||||||
|
expr = _legacySourceToSysSqlExpr(src, srcType, tgtType)
|
||||||
|
tq = _quotePgIdent(table)
|
||||||
|
tr = _quotePgIdent(tgt)
|
||||||
|
sr = _quotePgIdent(src)
|
||||||
|
sql = (
|
||||||
|
f"UPDATE {tq} SET {tr} = {expr} "
|
||||||
|
f"WHERE {tr} IS NULL AND {sr} IS NOT NULL"
|
||||||
|
)
|
||||||
|
cursor.execute(sql)
|
||||||
|
n = cursor.rowcount
|
||||||
|
self.connection.commit()
|
||||||
|
total += n
|
||||||
|
except Exception as e:
|
||||||
|
try:
|
||||||
|
self.connection.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
logger.debug(
|
||||||
|
f"migrateLegacyUnderscoreSysColumns skip {self.dbDatabase}.{table} "
|
||||||
|
f"{src}->{tgt}: {e}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"migrateLegacyUnderscoreSysColumns failed on {self.dbDatabase}: {e}")
|
||||||
|
try:
|
||||||
|
self.connection.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if total:
|
||||||
|
logger.info(
|
||||||
|
f"migrateLegacyUnderscoreSysColumns: {total} cell(s) in {self.dbDatabase}"
|
||||||
|
)
|
||||||
|
return total
|
||||||
|
|
||||||
def _initializeSystemTable(self):
|
def _initializeSystemTable(self):
|
||||||
"""Initializes the system table if it doesn't exist yet."""
|
"""Initializes the system table if it doesn't exist yet."""
|
||||||
try:
|
try:
|
||||||
|
|
@ -416,7 +558,7 @@ class DatabaseConnector:
|
||||||
for table_name, initial_id in data.items():
|
for table_name, initial_id in data.items():
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO "_system" ("table_name", "initial_id", "_modifiedAt")
|
INSERT INTO "_system" ("table_name", "initial_id", "sysModifiedAt")
|
||||||
VALUES (%s, %s, %s)
|
VALUES (%s, %s, %s)
|
||||||
""",
|
""",
|
||||||
(table_name, initial_id, getUtcTimestamp()),
|
(table_name, initial_id, getUtcTimestamp()),
|
||||||
|
|
@ -448,8 +590,10 @@ class DatabaseConnector:
|
||||||
CREATE TABLE "{self._systemTableName}" (
|
CREATE TABLE "{self._systemTableName}" (
|
||||||
"table_name" VARCHAR(255) PRIMARY KEY,
|
"table_name" VARCHAR(255) PRIMARY KEY,
|
||||||
"initial_id" VARCHAR(255),
|
"initial_id" VARCHAR(255),
|
||||||
"_createdAt" DOUBLE PRECISION,
|
"sysCreatedAt" DOUBLE PRECISION,
|
||||||
"_modifiedAt" DOUBLE PRECISION
|
"sysCreatedBy" VARCHAR(255),
|
||||||
|
"sysModifiedAt" DOUBLE PRECISION,
|
||||||
|
"sysModifiedBy" VARCHAR(255)
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
logger.info("System table created successfully")
|
logger.info("System table created successfully")
|
||||||
|
|
@ -464,10 +608,16 @@ class DatabaseConnector:
|
||||||
)
|
)
|
||||||
existing_columns = [row["column_name"] for row in cursor.fetchall()]
|
existing_columns = [row["column_name"] for row in cursor.fetchall()]
|
||||||
|
|
||||||
if "_modifiedAt" not in existing_columns:
|
for sys_col, sys_sql in [
|
||||||
cursor.execute(
|
("sysCreatedAt", "DOUBLE PRECISION"),
|
||||||
f'ALTER TABLE "{self._systemTableName}" ADD COLUMN "_modifiedAt" DOUBLE PRECISION'
|
("sysCreatedBy", "VARCHAR(255)"),
|
||||||
)
|
("sysModifiedAt", "DOUBLE PRECISION"),
|
||||||
|
("sysModifiedBy", "VARCHAR(255)"),
|
||||||
|
]:
|
||||||
|
if sys_col not in existing_columns:
|
||||||
|
cursor.execute(
|
||||||
|
f'ALTER TABLE "{self._systemTableName}" ADD COLUMN "{sys_col}" {sys_sql}'
|
||||||
|
)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -484,6 +634,7 @@ class DatabaseConnector:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._ensure_connection()
|
self._ensure_connection()
|
||||||
|
schemaTouched = False
|
||||||
|
|
||||||
with self.connection.cursor() as cursor:
|
with self.connection.cursor() as cursor:
|
||||||
# Check if table exists by querying information_schema with case-insensitive search
|
# Check if table exists by querying information_schema with case-insensitive search
|
||||||
|
|
@ -502,6 +653,7 @@ class DatabaseConnector:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Created table '{table}' with columns from Pydantic model"
|
f"Created table '{table}' with columns from Pydantic model"
|
||||||
)
|
)
|
||||||
|
schemaTouched = True
|
||||||
else:
|
else:
|
||||||
# Table exists: ensure all columns from model are present (simple additive migration)
|
# Table exists: ensure all columns from model are present (simple additive migration)
|
||||||
try:
|
try:
|
||||||
|
|
@ -518,11 +670,7 @@ class DatabaseConnector:
|
||||||
|
|
||||||
# Desired columns based on model
|
# Desired columns based on model
|
||||||
model_fields = _get_model_fields(model_class)
|
model_fields = _get_model_fields(model_class)
|
||||||
desired_columns = (
|
desired_columns = set(["id"]) | set(model_fields.keys())
|
||||||
set(["id"])
|
|
||||||
| set(model_fields.keys())
|
|
||||||
| {"_createdAt", "_modifiedAt", "_createdBy", "_modifiedBy"}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add missing columns
|
# Add missing columns
|
||||||
for col in sorted(desired_columns - existing_columns):
|
for col in sorted(desired_columns - existing_columns):
|
||||||
|
|
@ -530,12 +678,6 @@ class DatabaseConnector:
|
||||||
if col in ["id"]:
|
if col in ["id"]:
|
||||||
continue # primary key exists already
|
continue # primary key exists already
|
||||||
sql_type = model_fields.get(col)
|
sql_type = model_fields.get(col)
|
||||||
if col in ["_createdAt"]:
|
|
||||||
sql_type = "DOUBLE PRECISION"
|
|
||||||
elif col in ["_modifiedAt"]:
|
|
||||||
sql_type = "DOUBLE PRECISION"
|
|
||||||
elif col in ["_createdBy", "_modifiedBy"]:
|
|
||||||
sql_type = "VARCHAR(255)"
|
|
||||||
if not sql_type:
|
if not sql_type:
|
||||||
sql_type = "TEXT"
|
sql_type = "TEXT"
|
||||||
try:
|
try:
|
||||||
|
|
@ -545,6 +687,7 @@ class DatabaseConnector:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Added missing column '{col}' ({sql_type}) to '{table}'"
|
f"Added missing column '{col}' ({sql_type}) to '{table}'"
|
||||||
)
|
)
|
||||||
|
schemaTouched = True
|
||||||
except Exception as add_err:
|
except Exception as add_err:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Could not add column '{col}' to '{table}': {add_err}"
|
f"Could not add column '{col}' to '{table}': {add_err}"
|
||||||
|
|
@ -555,6 +698,23 @@ class DatabaseConnector:
|
||||||
)
|
)
|
||||||
|
|
||||||
self.connection.commit()
|
self.connection.commit()
|
||||||
|
if schemaTouched:
|
||||||
|
try:
|
||||||
|
n = self.migrateLegacyUnderscoreSysColumns()
|
||||||
|
if n:
|
||||||
|
logger.info(
|
||||||
|
"After schema change on %s.%s: legacy -> sys* migration wrote %s cell(s)",
|
||||||
|
self.dbDatabase,
|
||||||
|
table,
|
||||||
|
n,
|
||||||
|
)
|
||||||
|
except Exception as mig_err:
|
||||||
|
logger.error(
|
||||||
|
"migrateLegacyUnderscoreSysColumns failed after schema change %s.%s: %s",
|
||||||
|
self.dbDatabase,
|
||||||
|
table,
|
||||||
|
mig_err,
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error ensuring table {table} exists: {e}")
|
logger.error(f"Error ensuring table {table} exists: {e}")
|
||||||
|
|
@ -594,16 +754,6 @@ class DatabaseConnector:
|
||||||
if field_name != "id": # Skip id, already defined
|
if field_name != "id": # Skip id, already defined
|
||||||
columns.append(f'"{field_name}" {sql_type}')
|
columns.append(f'"{field_name}" {sql_type}')
|
||||||
|
|
||||||
# Add metadata columns
|
|
||||||
columns.extend(
|
|
||||||
[
|
|
||||||
'"_createdAt" DOUBLE PRECISION',
|
|
||||||
'"_modifiedAt" DOUBLE PRECISION',
|
|
||||||
'"_createdBy" VARCHAR(255)',
|
|
||||||
'"_modifiedBy" VARCHAR(255)',
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create table
|
# Create table
|
||||||
sql = f'CREATE TABLE IF NOT EXISTS "{table}" ({", ".join(columns)})'
|
sql = f'CREATE TABLE IF NOT EXISTS "{table}" ({", ".join(columns)})'
|
||||||
cursor.execute(sql)
|
cursor.execute(sql)
|
||||||
|
|
@ -626,11 +776,7 @@ class DatabaseConnector:
|
||||||
"""Save record to normalized table with explicit columns."""
|
"""Save record to normalized table with explicit columns."""
|
||||||
# Get columns from Pydantic model instead of database schema
|
# Get columns from Pydantic model instead of database schema
|
||||||
fields = _get_model_fields(model_class)
|
fields = _get_model_fields(model_class)
|
||||||
columns = (
|
columns = ["id"] + [field for field in fields.keys() if field != "id"]
|
||||||
["id"]
|
|
||||||
+ [field for field in fields.keys() if field != "id"]
|
|
||||||
+ ["_createdAt", "_createdBy", "_modifiedAt", "_modifiedBy"]
|
|
||||||
)
|
|
||||||
|
|
||||||
if not columns:
|
if not columns:
|
||||||
logger.error(f"No columns found for table {table}")
|
logger.error(f"No columns found for table {table}")
|
||||||
|
|
@ -648,7 +794,7 @@ class DatabaseConnector:
|
||||||
value = filtered_record.get(col)
|
value = filtered_record.get(col)
|
||||||
|
|
||||||
# Handle timestamp fields - store as Unix timestamps (floats) for consistency
|
# Handle timestamp fields - store as Unix timestamps (floats) for consistency
|
||||||
if col in ["_createdAt", "_modifiedAt"] and value is not None:
|
if col in ["sysCreatedAt", "sysModifiedAt"] and value is not None:
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
# Try to parse string as timestamp
|
# Try to parse string as timestamp
|
||||||
try:
|
try:
|
||||||
|
|
@ -690,7 +836,7 @@ class DatabaseConnector:
|
||||||
[
|
[
|
||||||
f'"{col}" = EXCLUDED."{col}"'
|
f'"{col}" = EXCLUDED."{col}"'
|
||||||
for col in columns[1:]
|
for col in columns[1:]
|
||||||
if col not in ["_createdAt", "_createdBy"]
|
if col not in ["sysCreatedAt", "sysCreatedBy"]
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -723,6 +869,10 @@ class DatabaseConnector:
|
||||||
logger.error(f"Error loading record {recordId} from table {table}: {e}")
|
logger.error(f"Error loading record {recordId} from table {table}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def getRecord(self, model_class: type, recordId: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Load one row by primary key (routes / services; wraps _loadRecord)."""
|
||||||
|
return self._loadRecord(model_class, str(recordId))
|
||||||
|
|
||||||
def _saveRecord(
|
def _saveRecord(
|
||||||
self, model_class: type, recordId: str, record: Dict[str, Any]
|
self, model_class: type, recordId: str, record: Dict[str, Any]
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
|
@ -742,17 +892,19 @@ class DatabaseConnector:
|
||||||
if effective_user_id is None:
|
if effective_user_id is None:
|
||||||
effective_user_id = self.userId
|
effective_user_id = self.userId
|
||||||
currentTime = getUtcTimestamp()
|
currentTime = getUtcTimestamp()
|
||||||
# Set _createdAt and _createdBy if this is a new record (record doesn't have _createdAt)
|
# Set sysCreatedAt/sysCreatedBy on first persist; always refresh modified fields.
|
||||||
if "_createdAt" not in record:
|
# Treat None and 0 as unset (legacy rows / bad defaults); model_dump often has sysCreatedAt=None.
|
||||||
record["_createdAt"] = currentTime
|
createdTs = record.get("sysCreatedAt")
|
||||||
|
if createdTs is None or createdTs == 0 or createdTs == 0.0:
|
||||||
|
record["sysCreatedAt"] = currentTime
|
||||||
if effective_user_id:
|
if effective_user_id:
|
||||||
record["_createdBy"] = effective_user_id
|
record["sysCreatedBy"] = effective_user_id
|
||||||
elif "_createdBy" not in record or not record.get("_createdBy"):
|
elif not record.get("sysCreatedBy"):
|
||||||
if effective_user_id:
|
if effective_user_id:
|
||||||
record["_createdBy"] = effective_user_id
|
record["sysCreatedBy"] = effective_user_id
|
||||||
record["_modifiedAt"] = currentTime
|
record["sysModifiedAt"] = currentTime
|
||||||
if effective_user_id:
|
if effective_user_id:
|
||||||
record["_modifiedBy"] = effective_user_id
|
record["sysModifiedBy"] = effective_user_id
|
||||||
|
|
||||||
with self.connection.cursor() as cursor:
|
with self.connection.cursor() as cursor:
|
||||||
self._save_record(cursor, table, recordId, record, model_class)
|
self._save_record(cursor, table, recordId, record, model_class)
|
||||||
|
|
@ -840,6 +992,26 @@ class DatabaseConnector:
|
||||||
logger.error(f"Error removing initial ID for table {table}: {e}")
|
logger.error(f"Error removing initial ID for table {table}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def buildRbacWhereClause(
|
||||||
|
self,
|
||||||
|
permissions: UserPermissions,
|
||||||
|
currentUser: User,
|
||||||
|
table: str,
|
||||||
|
mandateId: Optional[str] = None,
|
||||||
|
featureInstanceId: Optional[str] = None,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Delegate to interfaceRbac.buildRbacWhereClause (tests and call sites use connector as entry)."""
|
||||||
|
from modules.interfaces.interfaceRbac import buildRbacWhereClause as _buildRbacWhereClause
|
||||||
|
|
||||||
|
return _buildRbacWhereClause(
|
||||||
|
permissions,
|
||||||
|
currentUser,
|
||||||
|
table,
|
||||||
|
self,
|
||||||
|
mandateId=mandateId,
|
||||||
|
featureInstanceId=featureInstanceId,
|
||||||
|
)
|
||||||
|
|
||||||
def updateContext(self, userId: str) -> None:
|
def updateContext(self, userId: str) -> None:
|
||||||
"""Updates the context of the database connector.
|
"""Updates the context of the database connector.
|
||||||
Sets both instance userId and contextvar for request-scoped use when connector is shared.
|
Sets both instance userId and contextvar for request-scoped use when connector is shared.
|
||||||
|
|
@ -992,10 +1164,6 @@ class DatabaseConnector:
|
||||||
Returns (where_clause, order_clause, limit_clause, values, count_values).
|
Returns (where_clause, order_clause, limit_clause, values, count_values).
|
||||||
"""
|
"""
|
||||||
fields = _get_model_fields(model_class)
|
fields = _get_model_fields(model_class)
|
||||||
fields["_createdAt"] = "DOUBLE PRECISION"
|
|
||||||
fields["_modifiedAt"] = "DOUBLE PRECISION"
|
|
||||||
fields["_createdBy"] = "TEXT"
|
|
||||||
fields["_modifiedBy"] = "TEXT"
|
|
||||||
validColumns = set(fields.keys())
|
validColumns = set(fields.keys())
|
||||||
where_parts: List[str] = []
|
where_parts: List[str] = []
|
||||||
values: List[Any] = []
|
values: List[Any] = []
|
||||||
|
|
@ -1193,10 +1361,6 @@ class DatabaseConnector:
|
||||||
"""
|
"""
|
||||||
table = model_class.__name__
|
table = model_class.__name__
|
||||||
fields = _get_model_fields(model_class)
|
fields = _get_model_fields(model_class)
|
||||||
fields["_createdAt"] = "DOUBLE PRECISION"
|
|
||||||
fields["_modifiedAt"] = "DOUBLE PRECISION"
|
|
||||||
fields["_createdBy"] = "TEXT"
|
|
||||||
fields["_modifiedBy"] = "TEXT"
|
|
||||||
|
|
||||||
if column not in fields:
|
if column not in fields:
|
||||||
return []
|
return []
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,11 @@ from modules.shared.configuration import APP_CONFIG
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Gemini-TTS speaker IDs from voices.list use short names (e.g. "Kore") and require model_name + prompt.
|
||||||
|
_GEMINI_TTS_DEFAULT_MODEL = "gemini-2.5-flash-tts"
|
||||||
|
_GEMINI_TTS_NEUTRAL_PROMPT = "Say the following"
|
||||||
|
|
||||||
|
|
||||||
class ConnectorGoogleSpeech:
|
class ConnectorGoogleSpeech:
|
||||||
"""
|
"""
|
||||||
Google Cloud Speech-to-Text and Translation connector.
|
Google Cloud Speech-to-Text and Translation connector.
|
||||||
|
|
@ -902,6 +907,13 @@ class ConnectorGoogleSpeech:
|
||||||
"error": f"Validation error: {e}"
|
"error": f"Validation error: {e}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _isGeminiTtsSpeakerVoiceName(self, voiceName: str) -> bool:
|
||||||
|
"""True when voice name is a Gemini-TTS speaker id (no BCP-47 prefix like en-US-...)."""
|
||||||
|
if not voiceName or not isinstance(voiceName, str):
|
||||||
|
return False
|
||||||
|
stripped = voiceName.strip()
|
||||||
|
return bool(stripped) and "-" not in stripped
|
||||||
|
|
||||||
async def textToSpeech(self, text: str, languageCode: str = "de-DE", voiceName: str = None) -> Dict[str, Any]:
|
async def textToSpeech(self, text: str, languageCode: str = "de-DE", voiceName: str = None) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Convert text to speech using Google Cloud Text-to-Speech.
|
Convert text to speech using Google Cloud Text-to-Speech.
|
||||||
|
|
@ -917,9 +929,6 @@ class ConnectorGoogleSpeech:
|
||||||
try:
|
try:
|
||||||
logger.info(f"Converting text to speech: '{text[:50]}...' in {languageCode}")
|
logger.info(f"Converting text to speech: '{text[:50]}...' in {languageCode}")
|
||||||
|
|
||||||
# Set up the synthesis input
|
|
||||||
synthesisInput = texttospeech.SynthesisInput(text=text)
|
|
||||||
|
|
||||||
# Build the voice request
|
# Build the voice request
|
||||||
selectedVoice = voiceName or self._getDefaultVoice(languageCode)
|
selectedVoice = voiceName or self._getDefaultVoice(languageCode)
|
||||||
|
|
||||||
|
|
@ -931,11 +940,24 @@ class ConnectorGoogleSpeech:
|
||||||
|
|
||||||
logger.info(f"Using TTS voice: {selectedVoice} for language: {languageCode}")
|
logger.info(f"Using TTS voice: {selectedVoice} for language: {languageCode}")
|
||||||
|
|
||||||
voice = texttospeech.VoiceSelectionParams(
|
if self._isGeminiTtsSpeakerVoiceName(selectedVoice):
|
||||||
language_code=languageCode,
|
synthesisInput = texttospeech.SynthesisInput(
|
||||||
name=selectedVoice,
|
text=text,
|
||||||
ssml_gender=texttospeech.SsmlVoiceGender.NEUTRAL
|
prompt=_GEMINI_TTS_NEUTRAL_PROMPT,
|
||||||
)
|
)
|
||||||
|
voice = texttospeech.VoiceSelectionParams(
|
||||||
|
language_code=languageCode,
|
||||||
|
name=selectedVoice,
|
||||||
|
model_name=_GEMINI_TTS_DEFAULT_MODEL,
|
||||||
|
ssml_gender=texttospeech.SsmlVoiceGender.NEUTRAL,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
synthesisInput = texttospeech.SynthesisInput(text=text)
|
||||||
|
voice = texttospeech.VoiceSelectionParams(
|
||||||
|
language_code=languageCode,
|
||||||
|
name=selectedVoice,
|
||||||
|
ssml_gender=texttospeech.SsmlVoiceGender.NEUTRAL,
|
||||||
|
)
|
||||||
|
|
||||||
# Select the type of audio file to return
|
# Select the type of audio file to return
|
||||||
audioConfig = texttospeech.AudioConfig(
|
audioConfig = texttospeech.AudioConfig(
|
||||||
|
|
@ -1059,7 +1081,8 @@ class ConnectorGoogleSpeech:
|
||||||
"language_codes": list(voice.language_codes) if voice.language_codes else [],
|
"language_codes": list(voice.language_codes) if voice.language_codes else [],
|
||||||
"gender": gender,
|
"gender": gender,
|
||||||
"ssml_gender": voice.ssml_gender.name if voice.ssml_gender else "NEUTRAL",
|
"ssml_gender": voice.ssml_gender.name if voice.ssml_gender else "NEUTRAL",
|
||||||
"natural_sample_rate_hertz": voice.natural_sample_rate_hertz
|
"natural_sample_rate_hertz": voice.natural_sample_rate_hertz,
|
||||||
|
"geminiTts": self._isGeminiTtsSpeakerVoiceName(voice.name or ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Include any additional fields if available from Google API
|
# Include any additional fields if available from Google API
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,10 @@ class OperationTypeEnum(str, Enum):
|
||||||
IMAGE_ANALYSE = "imageAnalyse"
|
IMAGE_ANALYSE = "imageAnalyse"
|
||||||
IMAGE_GENERATE = "imageGenerate"
|
IMAGE_GENERATE = "imageGenerate"
|
||||||
|
|
||||||
|
# Neutralization (dedicated model selection; text vs vision backends)
|
||||||
|
NEUTRALIZATION_TEXT = "neutralizationText"
|
||||||
|
NEUTRALIZATION_IMAGE = "neutralizationImage"
|
||||||
|
|
||||||
# Web Operations
|
# Web Operations
|
||||||
WEB_SEARCH_DATA = "webSearch" # Returns list of URLs only
|
WEB_SEARCH_DATA = "webSearch" # Returns list of URLs only
|
||||||
WEB_CRAWL = "webCrawl" # Web crawl for a given URL
|
WEB_CRAWL = "webCrawl" # Web crawl for a given URL
|
||||||
|
|
@ -168,6 +172,7 @@ class AiCallRequest(BaseModel):
|
||||||
contentParts: Optional[List['ContentPart']] = None # Content parts for model-aware chunking
|
contentParts: Optional[List['ContentPart']] = None # Content parts for model-aware chunking
|
||||||
messages: Optional[List[Dict[str, Any]]] = Field(default=None, description="OpenAI-style messages for multi-turn agent conversations")
|
messages: Optional[List[Dict[str, Any]]] = Field(default=None, description="OpenAI-style messages for multi-turn agent conversations")
|
||||||
tools: Optional[List[Dict[str, Any]]] = Field(default=None, description="Tool definitions for native function calling")
|
tools: Optional[List[Dict[str, Any]]] = Field(default=None, description="Tool definitions for native function calling")
|
||||||
|
requireNeutralization: Optional[bool] = Field(default=None, description="Per-request neutralization override: True=force, False=skip, None=use config")
|
||||||
|
|
||||||
|
|
||||||
class AiCallResponse(BaseModel):
|
class AiCallResponse(BaseModel):
|
||||||
|
|
|
||||||
68
modules/datamodels/datamodelBase.py
Normal file
68
modules/datamodels/datamodelBase.py
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""Base Pydantic model with system-managed fields (DB + API + UI metadata)."""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from modules.shared.attributeUtils import registerModelLabels
|
||||||
|
|
||||||
|
|
||||||
|
class PowerOnModel(BaseModel):
|
||||||
|
sysCreatedAt: Optional[float] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Record creation timestamp (UTC, set by system)",
|
||||||
|
json_schema_extra={
|
||||||
|
"frontend_type": "timestamp",
|
||||||
|
"frontend_readonly": True,
|
||||||
|
"frontend_required": False,
|
||||||
|
"frontend_visible": False,
|
||||||
|
"system": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
sysCreatedBy: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="User ID who created this record (set by system)",
|
||||||
|
json_schema_extra={
|
||||||
|
"frontend_type": "text",
|
||||||
|
"frontend_readonly": True,
|
||||||
|
"frontend_required": False,
|
||||||
|
"frontend_visible": False,
|
||||||
|
"system": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
sysModifiedAt: Optional[float] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Record last modification timestamp (UTC, set by system)",
|
||||||
|
json_schema_extra={
|
||||||
|
"frontend_type": "timestamp",
|
||||||
|
"frontend_readonly": True,
|
||||||
|
"frontend_required": False,
|
||||||
|
"frontend_visible": False,
|
||||||
|
"system": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
sysModifiedBy: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="User ID who last modified this record (set by system)",
|
||||||
|
json_schema_extra={
|
||||||
|
"frontend_type": "text",
|
||||||
|
"frontend_readonly": True,
|
||||||
|
"frontend_required": False,
|
||||||
|
"frontend_visible": False,
|
||||||
|
"system": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
registerModelLabels(
|
||||||
|
"PowerOnModel",
|
||||||
|
{"en": "Base Record", "de": "Basisdatensatz"},
|
||||||
|
{
|
||||||
|
"sysCreatedAt": {"en": "Created At", "de": "Erstellt am", "fr": "Cree le"},
|
||||||
|
"sysCreatedBy": {"en": "Created By", "de": "Erstellt von", "fr": "Cree par"},
|
||||||
|
"sysModifiedAt": {"en": "Modified At", "de": "Geaendert am", "fr": "Modifie le"},
|
||||||
|
"sysModifiedBy": {"en": "Modified By", "de": "Geaendert von", "fr": "Modifie par"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
@ -6,24 +6,12 @@ from typing import List, Dict, Any, Optional
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from modules.datamodels.datamodelBase import PowerOnModel
|
||||||
from modules.shared.attributeUtils import registerModelLabels
|
from modules.shared.attributeUtils import registerModelLabels
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
# End-customer price for storage above plan-included volume (CHF per GB per month).
|
||||||
class BillingModelEnum(str, Enum):
|
STORAGE_PRICE_PER_GB_CHF = 0.50
|
||||||
"""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_USER = "PREPAY_USER" # Prepaid budget per user within mandate
|
|
||||||
|
|
||||||
|
|
||||||
# Nur fuer initRootMandateBilling (Root-Mandant PREPAY_USER + Startguthaben in Settings).
|
|
||||||
DEFAULT_USER_CREDIT_CHF = 5.0
|
|
||||||
|
|
||||||
|
|
||||||
class AccountTypeEnum(str, Enum):
|
|
||||||
"""Account type for billing accounts."""
|
|
||||||
MANDATE = "MANDATE" # Account for entire mandate
|
|
||||||
USER = "USER" # Account for specific user within mandate
|
|
||||||
|
|
||||||
|
|
||||||
class TransactionTypeEnum(str, Enum):
|
class TransactionTypeEnum(str, Enum):
|
||||||
|
|
@ -39,6 +27,8 @@ class ReferenceTypeEnum(str, Enum):
|
||||||
PAYMENT = "PAYMENT" # Payment/top-up
|
PAYMENT = "PAYMENT" # Payment/top-up
|
||||||
ADMIN = "ADMIN" # Admin adjustment
|
ADMIN = "ADMIN" # Admin adjustment
|
||||||
SYSTEM = "SYSTEM" # System credit (e.g., initial credit)
|
SYSTEM = "SYSTEM" # System credit (e.g., initial credit)
|
||||||
|
STORAGE = "STORAGE" # Metered storage overage (prepay pool)
|
||||||
|
SUBSCRIPTION = "SUBSCRIPTION" # AI budget credit from subscription plan
|
||||||
|
|
||||||
|
|
||||||
class PeriodTypeEnum(str, Enum):
|
class PeriodTypeEnum(str, Enum):
|
||||||
|
|
@ -48,14 +38,13 @@ class PeriodTypeEnum(str, Enum):
|
||||||
YEAR = "YEAR"
|
YEAR = "YEAR"
|
||||||
|
|
||||||
|
|
||||||
class BillingAccount(BaseModel):
|
class BillingAccount(PowerOnModel):
|
||||||
"""Billing account for mandate or user-mandate combination."""
|
"""Billing account for mandate or user-mandate combination."""
|
||||||
id: str = Field(
|
id: str = Field(
|
||||||
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
|
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
|
||||||
)
|
)
|
||||||
mandateId: str = Field(..., description="Foreign key to Mandate")
|
mandateId: str = Field(..., description="Foreign key to Mandate")
|
||||||
userId: Optional[str] = Field(None, description="Foreign key to User (only for PREPAY_USER)")
|
userId: Optional[str] = Field(None, description="Foreign key to User (None = mandate pool account, set = user audit account)")
|
||||||
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")
|
||||||
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")
|
||||||
|
|
@ -69,7 +58,6 @@ 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"},
|
||||||
"userId": {"en": "User ID", "de": "Benutzer-ID"},
|
"userId": {"en": "User ID", "de": "Benutzer-ID"},
|
||||||
"accountType": {"en": "Account Type", "de": "Kontotyp"},
|
|
||||||
"balance": {"en": "Balance (CHF)", "de": "Guthaben (CHF)"},
|
"balance": {"en": "Balance (CHF)", "de": "Guthaben (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"},
|
||||||
|
|
@ -78,7 +66,7 @@ registerModelLabels(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class BillingTransaction(BaseModel):
|
class BillingTransaction(PowerOnModel):
|
||||||
"""Single billing transaction (credit, debit, adjustment)."""
|
"""Single billing transaction (credit, debit, adjustment)."""
|
||||||
id: str = Field(
|
id: str = Field(
|
||||||
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
|
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
|
||||||
|
|
@ -129,30 +117,43 @@ registerModelLabels(
|
||||||
|
|
||||||
|
|
||||||
class BillingSettings(BaseModel):
|
class BillingSettings(BaseModel):
|
||||||
"""Billing settings per mandate."""
|
"""Billing settings per mandate. Only PREPAY_MANDATE model."""
|
||||||
id: str = Field(
|
id: str = Field(
|
||||||
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
|
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
|
||||||
)
|
)
|
||||||
mandateId: str = Field(..., description="Foreign key to Mandate (UNIQUE)")
|
mandateId: str = Field(..., description="Foreign key to Mandate (UNIQUE)")
|
||||||
billingModel: BillingModelEnum = Field(..., description="Billing model")
|
|
||||||
|
|
||||||
# Configuration
|
|
||||||
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")
|
||||||
|
|
||||||
# Stripe
|
# Stripe
|
||||||
stripeCustomerId: Optional[str] = Field(None, description="Stripe Customer ID (cus_xxx) — one per mandate")
|
stripeCustomerId: Optional[str] = Field(None, description="Stripe Customer ID (cus_xxx) — one per mandate")
|
||||||
|
|
||||||
# Notifications (e.g. mandate owner / finance — also used when PREPAY_MANDATE pool is exhausted)
|
# Auto-Recharge for AI budget
|
||||||
|
autoRechargeEnabled: bool = Field(default=False, description="Auto-buy AI budget when low")
|
||||||
|
rechargeAmountCHF: float = Field(default=10.0, description="Amount per auto-recharge (CHF, prepaid via Stripe)")
|
||||||
|
rechargeMaxPerMonth: int = Field(default=3, description="Max auto-recharges per month")
|
||||||
|
rechargesThisMonth: int = Field(default=0, description="Counter: auto-recharges used this month")
|
||||||
|
monthResetAt: Optional[datetime] = Field(None, description="When rechargesThisMonth was last reset")
|
||||||
|
|
||||||
|
# Notifications
|
||||||
notifyEmails: List[str] = Field(
|
notifyEmails: List[str] = Field(
|
||||||
default_factory=list,
|
default_factory=list,
|
||||||
description="Email addresses for billing alerts (mandate pool exhausted, warnings, etc.)",
|
description="Email addresses for billing alerts (pool exhausted, warnings, etc.)",
|
||||||
)
|
)
|
||||||
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")
|
||||||
|
|
||||||
|
# Storage overage (high-watermark within subscription period; resets on new period)
|
||||||
|
storageHighWatermarkMB: float = Field(
|
||||||
|
default=0.0, description="Peak indexed data volume MB this billing period"
|
||||||
|
)
|
||||||
|
storagePeriodStartAt: Optional[datetime] = Field(
|
||||||
|
None, description="Subscription billing period start used for storage reset"
|
||||||
|
)
|
||||||
|
storageBilledUpToMB: float = Field(
|
||||||
|
default=0.0,
|
||||||
|
description="Overage MB already debited this period (above plan-included volume)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
registerModelLabels(
|
registerModelLabels(
|
||||||
"BillingSettings",
|
"BillingSettings",
|
||||||
|
|
@ -160,18 +161,22 @@ 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"},
|
|
||||||
"defaultUserCredit": {
|
|
||||||
"en": "Root start credit (CHF)",
|
|
||||||
"de": "Startguthaben nur Root-Mandant (CHF)",
|
|
||||||
},
|
|
||||||
"warningThresholdPercent": {"en": "Warning Threshold (%)", "de": "Warnschwelle (%)"},
|
"warningThresholdPercent": {"en": "Warning Threshold (%)", "de": "Warnschwelle (%)"},
|
||||||
"stripeCustomerId": {"en": "Stripe Customer ID", "de": "Stripe-Kunden-ID"},
|
"stripeCustomerId": {"en": "Stripe Customer ID", "de": "Stripe-Kunden-ID"},
|
||||||
|
"autoRechargeEnabled": {"en": "Auto-Recharge", "de": "Auto-Nachladung"},
|
||||||
|
"rechargeAmountCHF": {"en": "Recharge Amount (CHF)", "de": "Nachladebetrag (CHF)"},
|
||||||
|
"rechargeMaxPerMonth": {"en": "Max Recharges/Month", "de": "Max. Nachladungen/Monat"},
|
||||||
"notifyEmails": {
|
"notifyEmails": {
|
||||||
"en": "Billing notification emails (owner / admin)",
|
"en": "Billing notification emails (owner / admin)",
|
||||||
"de": "E-Mails für Billing-Alerts (Inhaber/Admin)",
|
"de": "E-Mails fuer Billing-Alerts (Inhaber/Admin)",
|
||||||
},
|
},
|
||||||
"notifyOnWarning": {"en": "Notify on Warning", "de": "Bei Warnung benachrichtigen"},
|
"notifyOnWarning": {"en": "Notify on Warning", "de": "Bei Warnung benachrichtigen"},
|
||||||
|
"storageHighWatermarkMB": {"en": "Storage peak (MB)", "de": "Speicher-Peak (MB)"},
|
||||||
|
"storagePeriodStartAt": {"en": "Storage period start", "de": "Speicher-Periodenbeginn"},
|
||||||
|
"storageBilledUpToMB": {
|
||||||
|
"en": "Storage billed overage (MB)",
|
||||||
|
"de": "Speicher abgerechneter Überhang (MB)",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -238,7 +243,6 @@ class BillingBalanceResponse(BaseModel):
|
||||||
"""Response model for balance endpoint."""
|
"""Response model for balance endpoint."""
|
||||||
mandateId: str
|
mandateId: str
|
||||||
mandateName: str
|
mandateName: str
|
||||||
billingModel: BillingModelEnum
|
|
||||||
balance: float
|
balance: float
|
||||||
currency: str = "CHF"
|
currency: str = "CHF"
|
||||||
warningThreshold: float
|
warningThreshold: float
|
||||||
|
|
@ -269,20 +273,8 @@ class BillingCheckResult(BaseModel):
|
||||||
reason: Optional[str] = None
|
reason: Optional[str] = None
|
||||||
currentBalance: Optional[float] = None
|
currentBalance: Optional[float] = None
|
||||||
requiredAmount: Optional[float] = None
|
requiredAmount: Optional[float] = None
|
||||||
billingModel: Optional[BillingModelEnum] = None
|
|
||||||
upgradeRequired: Optional[bool] = None
|
upgradeRequired: Optional[bool] = None
|
||||||
subscriptionUiPath: Optional[str] = None
|
subscriptionUiPath: Optional[str] = None
|
||||||
userAction: Optional[str] = None
|
userAction: Optional[str] = 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
|
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,13 @@
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from modules.datamodels.datamodelBase import PowerOnModel
|
||||||
from modules.shared.attributeUtils import registerModelLabels
|
from modules.shared.attributeUtils import registerModelLabels
|
||||||
from modules.shared.timeUtils import getUtcTimestamp
|
from modules.shared.timeUtils import getUtcTimestamp
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
class ChatLog(BaseModel):
|
class ChatLog(PowerOnModel):
|
||||||
"""Log entries for chat workflows. User-owned, no mandate context."""
|
"""Log entries for chat workflows. User-owned, no mandate context."""
|
||||||
id: str = Field(
|
id: str = Field(
|
||||||
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
|
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
|
||||||
|
|
@ -56,7 +57,7 @@ registerModelLabels(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ChatDocument(BaseModel):
|
class ChatDocument(PowerOnModel):
|
||||||
"""Documents attached to chat messages. User-owned, no mandate context."""
|
"""Documents attached to chat messages. User-owned, no mandate context."""
|
||||||
id: str = Field(
|
id: str = Field(
|
||||||
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
|
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
|
||||||
|
|
@ -163,7 +164,7 @@ registerModelLabels(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ChatMessage(BaseModel):
|
class ChatMessage(PowerOnModel):
|
||||||
"""Messages in chat workflows. User-owned, no mandate context."""
|
"""Messages in chat workflows. User-owned, no mandate context."""
|
||||||
id: str = Field(
|
id: str = Field(
|
||||||
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
|
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
|
||||||
|
|
@ -260,7 +261,7 @@ registerModelLabels(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ChatWorkflow(BaseModel):
|
class ChatWorkflow(PowerOnModel):
|
||||||
"""Chat workflow container. User-owned, no mandate context."""
|
"""Chat workflow container. User-owned, no mandate context."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||||
featureInstanceId: Optional[str] = Field(None, description="Feature instance ID for multi-tenancy isolation", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
featureInstanceId: Optional[str] = Field(None, description="Feature instance ID for multi-tenancy isolation", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,12 @@ Google Drive folder, FTP directory, etc.) for agent-accessible data containers.
|
||||||
|
|
||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, Optional
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from modules.datamodels.datamodelBase import PowerOnModel
|
||||||
from modules.shared.attributeUtils import registerModelLabels
|
from modules.shared.attributeUtils import registerModelLabels
|
||||||
from modules.shared.timeUtils import getUtcTimestamp
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
class DataSource(BaseModel):
|
class DataSource(PowerOnModel):
|
||||||
"""Configured external data source linked to a UserConnection."""
|
"""Configured external data source linked to a UserConnection."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
|
||||||
connectionId: str = Field(description="FK to UserConnection")
|
connectionId: str = Field(description="FK to UserConnection")
|
||||||
|
|
@ -31,7 +31,21 @@ class DataSource(BaseModel):
|
||||||
userId: str = Field(default="", description="Owner user ID")
|
userId: str = Field(default="", description="Owner user ID")
|
||||||
autoSync: bool = Field(default=False, description="Automatically sync on schedule")
|
autoSync: bool = Field(default=False, description="Automatically sync on schedule")
|
||||||
lastSynced: Optional[float] = Field(default=None, description="Last sync timestamp")
|
lastSynced: Optional[float] = Field(default=None, description="Last sync timestamp")
|
||||||
createdAt: float = Field(default_factory=getUtcTimestamp, description="Creation timestamp")
|
scope: str = Field(
|
||||||
|
default="personal",
|
||||||
|
description="Data visibility scope: personal, featureInstance, mandate, global",
|
||||||
|
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
|
||||||
|
{"value": "personal", "label": {"en": "Personal", "de": "Persönlich"}},
|
||||||
|
{"value": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}},
|
||||||
|
{"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}},
|
||||||
|
{"value": "global", "label": {"en": "Global", "de": "Global"}},
|
||||||
|
]}
|
||||||
|
)
|
||||||
|
neutralize: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Whether this data source should be neutralized before AI processing",
|
||||||
|
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
registerModelLabels(
|
registerModelLabels(
|
||||||
|
|
@ -49,7 +63,8 @@ registerModelLabels(
|
||||||
"userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
|
"userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
|
||||||
"autoSync": {"en": "Auto Sync", "de": "Auto-Sync", "fr": "Synchro auto"},
|
"autoSync": {"en": "Auto Sync", "de": "Auto-Sync", "fr": "Synchro auto"},
|
||||||
"lastSynced": {"en": "Last Synced", "de": "Letzter Sync", "fr": "Dernier sync"},
|
"lastSynced": {"en": "Last Synced", "de": "Letzter Sync", "fr": "Dernier sync"},
|
||||||
"createdAt": {"en": "Created At", "de": "Erstellt am", "fr": "Créé le"},
|
"scope": {"en": "Scope", "de": "Sichtbarkeit"},
|
||||||
|
"neutralize": {"en": "Neutralize", "de": "Neutralisieren"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,12 @@ so the agent can query structured feature data (e.g. TrusteePosition rows).
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from modules.datamodels.datamodelBase import PowerOnModel
|
||||||
from modules.shared.attributeUtils import registerModelLabels
|
from modules.shared.attributeUtils import registerModelLabels
|
||||||
from modules.shared.timeUtils import getUtcTimestamp
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
class FeatureDataSource(BaseModel):
|
class FeatureDataSource(PowerOnModel):
|
||||||
"""A feature-instance table attached as data source in the AI workspace."""
|
"""A feature-instance table attached as data source in the AI workspace."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
|
||||||
featureInstanceId: str = Field(description="FK to FeatureInstance")
|
featureInstanceId: str = Field(description="FK to FeatureInstance")
|
||||||
|
|
@ -24,7 +24,21 @@ class FeatureDataSource(BaseModel):
|
||||||
mandateId: str = Field(default="", description="Mandate scope")
|
mandateId: str = Field(default="", description="Mandate scope")
|
||||||
userId: str = Field(default="", description="Owner user ID")
|
userId: str = Field(default="", description="Owner user ID")
|
||||||
workspaceInstanceId: str = Field(description="Workspace instance where this source is used")
|
workspaceInstanceId: str = Field(description="Workspace instance where this source is used")
|
||||||
createdAt: float = Field(default_factory=getUtcTimestamp, description="Creation timestamp")
|
scope: str = Field(
|
||||||
|
default="personal",
|
||||||
|
description="Data visibility scope: personal, featureInstance, mandate, global",
|
||||||
|
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
|
||||||
|
{"value": "personal", "label": {"en": "Personal", "de": "Persönlich"}},
|
||||||
|
{"value": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}},
|
||||||
|
{"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}},
|
||||||
|
{"value": "global", "label": {"en": "Global", "de": "Global"}},
|
||||||
|
]}
|
||||||
|
)
|
||||||
|
neutralize: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Whether this data source should be neutralized before AI processing",
|
||||||
|
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
registerModelLabels(
|
registerModelLabels(
|
||||||
|
|
@ -40,6 +54,5 @@ registerModelLabels(
|
||||||
"mandateId": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
|
"mandateId": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
|
||||||
"userId": {"en": "User", "de": "Benutzer", "fr": "Utilisateur"},
|
"userId": {"en": "User", "de": "Benutzer", "fr": "Utilisateur"},
|
||||||
"workspaceInstanceId": {"en": "Workspace", "de": "Workspace", "fr": "Espace de travail"},
|
"workspaceInstanceId": {"en": "Workspace", "de": "Workspace", "fr": "Espace de travail"},
|
||||||
"createdAt": {"en": "Created At", "de": "Erstellt am", "fr": "Créé le"},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,12 @@
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from modules.datamodels.datamodelBase import PowerOnModel
|
||||||
from modules.shared.attributeUtils import registerModelLabels
|
from modules.shared.attributeUtils import registerModelLabels
|
||||||
from modules.datamodels.datamodelUtils import TextMultilingual
|
from modules.datamodels.datamodelUtils import TextMultilingual
|
||||||
|
|
||||||
|
|
||||||
class Feature(BaseModel):
|
class Feature(PowerOnModel):
|
||||||
"""
|
"""
|
||||||
Feature-Definition (global, z.B. 'trustee', 'chatbot').
|
Feature-Definition (global, z.B. 'trustee', 'chatbot').
|
||||||
Features sind die verfügbaren Funktionalitäten der Plattform.
|
Features sind die verfügbaren Funktionalitäten der Plattform.
|
||||||
|
|
@ -40,7 +41,7 @@ registerModelLabels(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class FeatureInstance(BaseModel):
|
class FeatureInstance(PowerOnModel):
|
||||||
"""
|
"""
|
||||||
Instanz eines Features in einem Mandanten.
|
Instanz eines Features in einem Mandanten.
|
||||||
Ein Mandant kann mehrere Instanzen desselben Features haben.
|
Ein Mandant kann mehrere Instanzen desselben Features haben.
|
||||||
|
|
|
||||||
|
|
@ -4,18 +4,17 @@
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from modules.datamodels.datamodelBase import PowerOnModel
|
||||||
from modules.shared.attributeUtils import registerModelLabels
|
from modules.shared.attributeUtils import registerModelLabels
|
||||||
from modules.shared.timeUtils import getUtcTimestamp
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
class FileFolder(BaseModel):
|
class FileFolder(PowerOnModel):
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||||
name: str = Field(description="Folder name", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True})
|
name: str = Field(description="Folder name", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True})
|
||||||
parentId: Optional[str] = Field(default=None, description="Parent folder ID (null = root)", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
|
parentId: Optional[str] = Field(default=None, description="Parent folder ID (null = root)", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
|
||||||
mandateId: Optional[str] = Field(default=None, description="Mandate context", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
mandateId: Optional[str] = Field(default=None, description="Mandate context", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||||
featureInstanceId: Optional[str] = Field(default=None, description="Feature instance context", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
featureInstanceId: Optional[str] = Field(default=None, description="Feature instance context", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||||
createdAt: float = Field(default_factory=getUtcTimestamp, description="Creation timestamp", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
|
|
||||||
|
|
||||||
|
|
||||||
registerModelLabels(
|
registerModelLabels(
|
||||||
|
|
@ -27,6 +26,5 @@ registerModelLabels(
|
||||||
"parentId": {"en": "Parent Folder", "fr": "Dossier parent"},
|
"parentId": {"en": "Parent Folder", "fr": "Dossier parent"},
|
||||||
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
|
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
|
||||||
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance"},
|
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance"},
|
||||||
"createdAt": {"en": "Created At", "fr": "Créé le"},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,14 @@
|
||||||
"""File-related datamodels: FileItem, FilePreview, FileData."""
|
"""File-related datamodels: FileItem, FilePreview, FileData."""
|
||||||
|
|
||||||
from typing import Dict, Any, List, Optional, Union
|
from typing import Dict, Any, List, Optional, Union
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from modules.datamodels.datamodelBase import PowerOnModel
|
||||||
from modules.shared.attributeUtils import registerModelLabels
|
from modules.shared.attributeUtils import registerModelLabels
|
||||||
from modules.shared.timeUtils import getUtcTimestamp
|
|
||||||
import uuid
|
import uuid
|
||||||
import base64
|
import base64
|
||||||
|
|
||||||
|
|
||||||
class FileItem(BaseModel):
|
class FileItem(PowerOnModel):
|
||||||
model_config = ConfigDict(extra='allow') # Preserve system fields (_createdBy, _createdAt, etc.)
|
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||||
mandateId: Optional[str] = Field(default="", description="ID of the mandate this file belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
mandateId: Optional[str] = Field(default="", description="ID of the mandate this file belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||||
featureInstanceId: Optional[str] = Field(default="", description="ID of the feature instance this file belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "frontend_fk_source": "/api/features/instances", "frontend_fk_display_field": "label"})
|
featureInstanceId: Optional[str] = Field(default="", description="ID of the feature instance this file belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "frontend_fk_source": "/api/features/instances", "frontend_fk_display_field": "label"})
|
||||||
|
|
@ -19,11 +18,25 @@ class FileItem(BaseModel):
|
||||||
mimeType: str = Field(description="MIME type of the file", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
mimeType: str = Field(description="MIME type of the file", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||||
fileHash: str = Field(description="Hash of the file", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
fileHash: str = Field(description="Hash of the file", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||||
fileSize: int = Field(description="Size of the file in bytes", json_schema_extra={"frontend_type": "integer", "frontend_readonly": True, "frontend_required": False})
|
fileSize: int = Field(description="Size of the file in bytes", json_schema_extra={"frontend_type": "integer", "frontend_readonly": True, "frontend_required": False})
|
||||||
creationDate: float = Field(default_factory=getUtcTimestamp, description="Date when the file was created (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
|
|
||||||
tags: Optional[List[str]] = Field(default=None, description="Tags for categorization and search", json_schema_extra={"frontend_type": "tags", "frontend_readonly": False, "frontend_required": False})
|
tags: Optional[List[str]] = Field(default=None, description="Tags for categorization and search", json_schema_extra={"frontend_type": "tags", "frontend_readonly": False, "frontend_required": False})
|
||||||
folderId: Optional[str] = Field(default=None, description="ID of the parent folder", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
|
folderId: Optional[str] = Field(default=None, description="ID of the parent folder", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
|
||||||
description: Optional[str] = Field(default=None, description="User-provided description of the file", json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False})
|
description: Optional[str] = Field(default=None, description="User-provided description of the file", json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False})
|
||||||
status: Optional[str] = Field(default=None, description="Processing status: pending, extracted, embedding, indexed, failed", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
status: Optional[str] = Field(default=None, description="Processing status: pending, extracted, embedding, indexed, failed", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||||
|
scope: str = Field(
|
||||||
|
default="personal",
|
||||||
|
description="Data visibility scope: personal, featureInstance, mandate, global",
|
||||||
|
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
|
||||||
|
{"value": "personal", "label": {"en": "Personal", "de": "Persönlich"}},
|
||||||
|
{"value": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}},
|
||||||
|
{"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}},
|
||||||
|
{"value": "global", "label": {"en": "Global", "de": "Global"}},
|
||||||
|
]}
|
||||||
|
)
|
||||||
|
neutralize: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Whether this file should be neutralized before AI processing",
|
||||||
|
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
|
||||||
|
)
|
||||||
|
|
||||||
registerModelLabels(
|
registerModelLabels(
|
||||||
"FileItem",
|
"FileItem",
|
||||||
|
|
@ -36,11 +49,12 @@ registerModelLabels(
|
||||||
"mimeType": {"en": "MIME Type", "fr": "Type MIME"},
|
"mimeType": {"en": "MIME Type", "fr": "Type MIME"},
|
||||||
"fileHash": {"en": "File Hash", "fr": "Hash du fichier"},
|
"fileHash": {"en": "File Hash", "fr": "Hash du fichier"},
|
||||||
"fileSize": {"en": "File Size", "fr": "Taille du fichier"},
|
"fileSize": {"en": "File Size", "fr": "Taille du fichier"},
|
||||||
"creationDate": {"en": "Creation Date", "fr": "Date de création"},
|
|
||||||
"tags": {"en": "Tags", "fr": "Tags"},
|
"tags": {"en": "Tags", "fr": "Tags"},
|
||||||
"folderId": {"en": "Folder ID", "fr": "ID du dossier"},
|
"folderId": {"en": "Folder ID", "fr": "ID du dossier"},
|
||||||
"description": {"en": "Description", "fr": "Description"},
|
"description": {"en": "Description", "fr": "Description"},
|
||||||
"status": {"en": "Status", "fr": "Statut"},
|
"status": {"en": "Status", "fr": "Statut"},
|
||||||
|
"scope": {"en": "Scope", "de": "Sichtbarkeit"},
|
||||||
|
"neutralize": {"en": "Neutralize", "de": "Neutralisieren"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -71,7 +85,7 @@ registerModelLabels(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
class FileData(BaseModel):
|
class FileData(PowerOnModel):
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
|
||||||
data: str = Field(description="File data content")
|
data: str = Field(description="File data content")
|
||||||
base64Encoded: bool = Field(description="Whether the data is base64 encoded")
|
base64Encoded: bool = Field(description="Whether the data is base64 encoded")
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,11 @@ import uuid
|
||||||
import secrets
|
import secrets
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from modules.datamodels.datamodelBase import PowerOnModel
|
||||||
from modules.shared.attributeUtils import registerModelLabels
|
from modules.shared.attributeUtils import registerModelLabels
|
||||||
from modules.shared.timeUtils import getUtcTimestamp
|
|
||||||
|
|
||||||
|
|
||||||
class Invitation(BaseModel):
|
class Invitation(PowerOnModel):
|
||||||
"""
|
"""
|
||||||
Einladungs-Token für neue User.
|
Einladungs-Token für neue User.
|
||||||
Ermöglicht Self-Service Onboarding zu Mandanten und Feature-Instanzen.
|
Ermöglicht Self-Service Onboarding zu Mandanten und Feature-Instanzen.
|
||||||
|
|
@ -56,15 +56,6 @@ class Invitation(BaseModel):
|
||||||
description="Email address to send invitation link (optional)",
|
description="Email address to send invitation link (optional)",
|
||||||
json_schema_extra={"frontend_type": "email", "frontend_readonly": False, "frontend_required": False}
|
json_schema_extra={"frontend_type": "email", "frontend_readonly": False, "frontend_required": False}
|
||||||
)
|
)
|
||||||
createdBy: str = Field(
|
|
||||||
description="User ID of the person who created the invitation",
|
|
||||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
|
|
||||||
)
|
|
||||||
createdAt: float = Field(
|
|
||||||
default_factory=getUtcTimestamp,
|
|
||||||
description="When the invitation was created (UTC timestamp)",
|
|
||||||
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
|
|
||||||
)
|
|
||||||
expiresAt: float = Field(
|
expiresAt: float = Field(
|
||||||
description="When the invitation expires (UTC timestamp)",
|
description="When the invitation expires (UTC timestamp)",
|
||||||
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": True}
|
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": True}
|
||||||
|
|
@ -121,8 +112,6 @@ registerModelLabels(
|
||||||
"roleIds": {"en": "Roles", "de": "Rollen", "fr": "Rôles"},
|
"roleIds": {"en": "Roles", "de": "Rollen", "fr": "Rôles"},
|
||||||
"targetUsername": {"en": "Target Username", "de": "Ziel-Benutzername", "fr": "Nom d'utilisateur cible"},
|
"targetUsername": {"en": "Target Username", "de": "Ziel-Benutzername", "fr": "Nom d'utilisateur cible"},
|
||||||
"email": {"en": "Email (optional)", "de": "E-Mail (optional)", "fr": "Email (optionnel)"},
|
"email": {"en": "Email (optional)", "de": "E-Mail (optional)", "fr": "Email (optionnel)"},
|
||||||
"createdBy": {"en": "Created By", "de": "Erstellt von", "fr": "Créé par"},
|
|
||||||
"createdAt": {"en": "Created At", "de": "Erstellt am", "fr": "Créé le"},
|
|
||||||
"expiresAt": {"en": "Expires At", "de": "Gültig bis", "fr": "Expire le"},
|
"expiresAt": {"en": "Expires At", "de": "Gültig bis", "fr": "Expire le"},
|
||||||
"usedBy": {"en": "Used By", "de": "Verwendet von", "fr": "Utilisé par"},
|
"usedBy": {"en": "Used By", "de": "Verwendet von", "fr": "Utilisé par"},
|
||||||
"usedAt": {"en": "Used At", "de": "Verwendet am", "fr": "Utilisé le"},
|
"usedAt": {"en": "Used At", "de": "Verwendet am", "fr": "Utilisé le"},
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@
|
||||||
"""Knowledge Store data models: FileContentIndex, ContentChunk, WorkflowMemory.
|
"""Knowledge Store data models: FileContentIndex, ContentChunk, WorkflowMemory.
|
||||||
|
|
||||||
These models support the 3-tier RAG architecture:
|
These models support the 3-tier RAG architecture:
|
||||||
- Shared Layer: mandateId-scoped, isShared=True
|
- Personal Layer: scope=personal, userId-scoped
|
||||||
- Instance Layer: userId + featureInstanceId-scoped
|
- Instance Layer: scope=featureInstance, featureInstanceId-scoped
|
||||||
|
- Mandate Layer: scope=mandate, mandateId-scoped (visible to all mandate users)
|
||||||
|
- Global Layer: scope=global (sysAdmin only)
|
||||||
- Workflow Layer: workflowId-scoped (WorkflowMemory)
|
- Workflow Layer: workflowId-scoped (WorkflowMemory)
|
||||||
|
|
||||||
Vector fields use json_schema_extra={"db_type": "vector(1536)"} for pgvector.
|
Vector fields use json_schema_extra={"db_type": "vector(1536)"} for pgvector.
|
||||||
|
|
@ -12,19 +14,19 @@ Vector fields use json_schema_extra={"db_type": "vector(1536)"} for pgvector.
|
||||||
|
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Optional
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from modules.datamodels.datamodelBase import PowerOnModel
|
||||||
from modules.shared.attributeUtils import registerModelLabels
|
from modules.shared.attributeUtils import registerModelLabels
|
||||||
from modules.shared.timeUtils import getUtcTimestamp
|
from modules.shared.timeUtils import getUtcTimestamp
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
class FileContentIndex(BaseModel):
|
class FileContentIndex(PowerOnModel):
|
||||||
"""Structural index of a file's content objects. Created without AI.
|
"""Structural index of a file's content objects. Created without AI.
|
||||||
Lives in the Instance Layer; optionally promoted to Shared Layer via isShared."""
|
Scope is mirrored from FileItem (poweron_management) at indexing time."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key (typically = fileId)")
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key (typically = fileId)")
|
||||||
userId: str = Field(description="Owner user ID")
|
userId: str = Field(description="Owner user ID")
|
||||||
featureInstanceId: str = Field(default="", description="Feature instance scope")
|
featureInstanceId: str = Field(default="", description="Feature instance scope")
|
||||||
mandateId: str = Field(default="", description="Mandate scope")
|
mandateId: str = Field(default="", description="Mandate scope")
|
||||||
isShared: bool = Field(default=False, description="Visible in Shared Layer for all mandate users")
|
|
||||||
fileName: str = Field(description="Original file name")
|
fileName: str = Field(description="Original file name")
|
||||||
mimeType: str = Field(description="MIME type of the file")
|
mimeType: str = Field(description="MIME type of the file")
|
||||||
containerPath: Optional[str] = Field(default=None, description="Path within a container (e.g. 'archive.zip/folder/report.pdf')")
|
containerPath: Optional[str] = Field(default=None, description="Path within a container (e.g. 'archive.zip/folder/report.pdf')")
|
||||||
|
|
@ -34,6 +36,18 @@ class FileContentIndex(BaseModel):
|
||||||
objectSummary: List[Dict[str, Any]] = Field(default_factory=list, description="Compact summary per content object")
|
objectSummary: List[Dict[str, Any]] = Field(default_factory=list, description="Compact summary per content object")
|
||||||
extractedAt: float = Field(default_factory=getUtcTimestamp, description="Extraction timestamp")
|
extractedAt: float = Field(default_factory=getUtcTimestamp, description="Extraction timestamp")
|
||||||
status: str = Field(default="pending", description="Processing status: pending, extracted, embedding, indexed, failed")
|
status: str = Field(default="pending", description="Processing status: pending, extracted, embedding, indexed, failed")
|
||||||
|
scope: str = Field(
|
||||||
|
default="personal",
|
||||||
|
description="Data visibility scope: personal, featureInstance, mandate, global",
|
||||||
|
)
|
||||||
|
neutralizationStatus: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Neutralization status: completed, failed, skipped, None = not required",
|
||||||
|
)
|
||||||
|
isNeutralized: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="True if content was neutralized before indexing",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
registerModelLabels(
|
registerModelLabels(
|
||||||
|
|
@ -44,7 +58,6 @@ registerModelLabels(
|
||||||
"userId": {"en": "User ID", "fr": "ID utilisateur"},
|
"userId": {"en": "User ID", "fr": "ID utilisateur"},
|
||||||
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance"},
|
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance"},
|
||||||
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
|
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
|
||||||
"isShared": {"en": "Shared", "fr": "Partagé"},
|
|
||||||
"fileName": {"en": "File Name", "fr": "Nom de fichier"},
|
"fileName": {"en": "File Name", "fr": "Nom de fichier"},
|
||||||
"mimeType": {"en": "MIME Type", "fr": "Type MIME"},
|
"mimeType": {"en": "MIME Type", "fr": "Type MIME"},
|
||||||
"containerPath": {"en": "Container Path", "fr": "Chemin du conteneur"},
|
"containerPath": {"en": "Container Path", "fr": "Chemin du conteneur"},
|
||||||
|
|
@ -54,11 +67,14 @@ registerModelLabels(
|
||||||
"objectSummary": {"en": "Object Summary", "fr": "Résumé des objets"},
|
"objectSummary": {"en": "Object Summary", "fr": "Résumé des objets"},
|
||||||
"extractedAt": {"en": "Extracted At", "fr": "Extrait le"},
|
"extractedAt": {"en": "Extracted At", "fr": "Extrait le"},
|
||||||
"status": {"en": "Status", "fr": "Statut"},
|
"status": {"en": "Status", "fr": "Statut"},
|
||||||
|
"scope": {"en": "Scope", "de": "Sichtbarkeit"},
|
||||||
|
"neutralizationStatus": {"en": "Neutralization Status", "de": "Neutralisierungsstatus"},
|
||||||
|
"isNeutralized": {"en": "Is Neutralized", "de": "Neutralisiert"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ContentChunk(BaseModel):
|
class ContentChunk(PowerOnModel):
|
||||||
"""Persisted content chunk with embedding vector. Reusable across workflows.
|
"""Persisted content chunk with embedding vector. Reusable across workflows.
|
||||||
Scalar content object (or chunk thereof) with pgvector embedding."""
|
Scalar content object (or chunk thereof) with pgvector embedding."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
|
||||||
|
|
@ -96,7 +112,7 @@ registerModelLabels(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class RoundMemory(BaseModel):
|
class RoundMemory(PowerOnModel):
|
||||||
"""Persistent per-round memory for agent tool results, file refs, and decisions.
|
"""Persistent per-round memory for agent tool results, file refs, and decisions.
|
||||||
|
|
||||||
Stored after each agent round so that RAG can retrieve relevant context
|
Stored after each agent round so that RAG can retrieve relevant context
|
||||||
|
|
@ -120,7 +136,6 @@ class RoundMemory(BaseModel):
|
||||||
description="Embedding of summary for semantic retrieval",
|
description="Embedding of summary for semantic retrieval",
|
||||||
json_schema_extra={"db_type": "vector(1536)"},
|
json_schema_extra={"db_type": "vector(1536)"},
|
||||||
)
|
)
|
||||||
createdAt: float = Field(default_factory=getUtcTimestamp, description="Creation timestamp")
|
|
||||||
|
|
||||||
|
|
||||||
registerModelLabels(
|
registerModelLabels(
|
||||||
|
|
@ -136,12 +151,11 @@ registerModelLabels(
|
||||||
"fullData": {"en": "Full Data", "fr": "Données complètes"},
|
"fullData": {"en": "Full Data", "fr": "Données complètes"},
|
||||||
"fileIds": {"en": "File IDs", "fr": "IDs de fichier"},
|
"fileIds": {"en": "File IDs", "fr": "IDs de fichier"},
|
||||||
"embedding": {"en": "Embedding", "fr": "Vecteur d'embedding"},
|
"embedding": {"en": "Embedding", "fr": "Vecteur d'embedding"},
|
||||||
"createdAt": {"en": "Created At", "fr": "Créé le"},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class WorkflowMemory(BaseModel):
|
class WorkflowMemory(PowerOnModel):
|
||||||
"""Workflow-scoped key-value cache for entities and facts.
|
"""Workflow-scoped key-value cache for entities and facts.
|
||||||
Extracted during agent rounds, persisted for cross-round and cross-workflow reuse."""
|
Extracted during agent rounds, persisted for cross-round and cross-workflow reuse."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
|
||||||
|
|
@ -151,7 +165,6 @@ class WorkflowMemory(BaseModel):
|
||||||
key: str = Field(description="Key identifier (e.g. 'entity:companyName')")
|
key: str = Field(description="Key identifier (e.g. 'entity:companyName')")
|
||||||
value: str = Field(description="Extracted value")
|
value: str = Field(description="Extracted value")
|
||||||
source: str = Field(default="extraction", description="Origin: extraction, tool, conversation, summary")
|
source: str = Field(default="extraction", description="Origin: extraction, tool, conversation, summary")
|
||||||
createdAt: float = Field(default_factory=getUtcTimestamp, description="Creation timestamp")
|
|
||||||
embedding: Optional[List[float]] = Field(
|
embedding: Optional[List[float]] = Field(
|
||||||
default=None, description="Optional embedding for semantic lookup",
|
default=None, description="Optional embedding for semantic lookup",
|
||||||
json_schema_extra={"db_type": "vector(1536)"}
|
json_schema_extra={"db_type": "vector(1536)"}
|
||||||
|
|
@ -169,7 +182,6 @@ registerModelLabels(
|
||||||
"key": {"en": "Key", "fr": "Clé"},
|
"key": {"en": "Key", "fr": "Clé"},
|
||||||
"value": {"en": "Value", "fr": "Valeur"},
|
"value": {"en": "Value", "fr": "Valeur"},
|
||||||
"source": {"en": "Source", "fr": "Source"},
|
"source": {"en": "Source", "fr": "Source"},
|
||||||
"createdAt": {"en": "Created At", "fr": "Créé le"},
|
|
||||||
"embedding": {"en": "Embedding", "fr": "Vecteur d'embedding"},
|
"embedding": {"en": "Embedding", "fr": "Vecteur d'embedding"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,11 @@ Rollen werden über Junction Tables verknüpft für saubere CASCADE DELETE.
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from modules.datamodels.datamodelBase import PowerOnModel
|
||||||
from modules.shared.attributeUtils import registerModelLabels
|
from modules.shared.attributeUtils import registerModelLabels
|
||||||
|
|
||||||
|
|
||||||
class UserMandate(BaseModel):
|
class UserMandate(PowerOnModel):
|
||||||
"""
|
"""
|
||||||
User-Mitgliedschaft in einem Mandanten.
|
User-Mitgliedschaft in einem Mandanten.
|
||||||
Kein User gehört direkt zu einem Mandanten - Zugehörigkeit wird über dieses Model gesteuert.
|
Kein User gehört direkt zu einem Mandanten - Zugehörigkeit wird über dieses Model gesteuert.
|
||||||
|
|
@ -50,7 +51,7 @@ registerModelLabels(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class FeatureAccess(BaseModel):
|
class FeatureAccess(PowerOnModel):
|
||||||
"""
|
"""
|
||||||
User-Zugriff auf eine Feature-Instanz.
|
User-Zugriff auf eine Feature-Instanz.
|
||||||
Definiert welche User auf welche Feature-Instanzen zugreifen können.
|
Definiert welche User auf welche Feature-Instanzen zugreifen können.
|
||||||
|
|
@ -88,7 +89,7 @@ registerModelLabels(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class UserMandateRole(BaseModel):
|
class UserMandateRole(PowerOnModel):
|
||||||
"""
|
"""
|
||||||
Junction Table: UserMandate zu Role.
|
Junction Table: UserMandate zu Role.
|
||||||
Ermöglicht CASCADE DELETE auf Datenbankebene.
|
Ermöglicht CASCADE DELETE auf Datenbankebene.
|
||||||
|
|
@ -119,7 +120,7 @@ registerModelLabels(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class FeatureAccessRole(BaseModel):
|
class FeatureAccessRole(PowerOnModel):
|
||||||
"""
|
"""
|
||||||
Junction Table: FeatureAccess zu Role.
|
Junction Table: FeatureAccess zu Role.
|
||||||
Ermöglicht CASCADE DELETE auf Datenbankebene.
|
Ermöglicht CASCADE DELETE auf Datenbankebene.
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ import uuid
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pydantic import BaseModel, Field, ConfigDict
|
from pydantic import BaseModel, Field, ConfigDict
|
||||||
|
from modules.datamodels.datamodelBase import PowerOnModel
|
||||||
from modules.shared.attributeUtils import registerModelLabels
|
from modules.shared.attributeUtils import registerModelLabels
|
||||||
from modules.shared.timeUtils import getUtcTimestamp
|
|
||||||
|
|
||||||
|
|
||||||
class MessagingChannel(str, Enum):
|
class MessagingChannel(str, Enum):
|
||||||
|
|
@ -26,7 +26,7 @@ class DeliveryStatus(str, Enum):
|
||||||
FAILED = "failed"
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
class MessagingSubscription(BaseModel):
|
class MessagingSubscription(PowerOnModel):
|
||||||
"""Data model for messaging subscriptions"""
|
"""Data model for messaging subscriptions"""
|
||||||
id: str = Field(
|
id: str = Field(
|
||||||
default_factory=lambda: str(uuid.uuid4()),
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
|
|
@ -64,26 +64,6 @@ class MessagingSubscription(BaseModel):
|
||||||
description="Whether the subscription is enabled",
|
description="Whether the subscription is enabled",
|
||||||
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
|
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
|
||||||
)
|
)
|
||||||
creationDate: float = Field(
|
|
||||||
default_factory=getUtcTimestamp,
|
|
||||||
description="When the subscription was created (UTC timestamp in seconds)",
|
|
||||||
json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False}
|
|
||||||
)
|
|
||||||
lastModified: float = Field(
|
|
||||||
default_factory=getUtcTimestamp,
|
|
||||||
description="When the subscription was last modified (UTC timestamp in seconds)",
|
|
||||||
json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False}
|
|
||||||
)
|
|
||||||
createdBy: Optional[str] = Field(
|
|
||||||
default=None,
|
|
||||||
description="User ID who created the subscription",
|
|
||||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
|
||||||
)
|
|
||||||
modifiedBy: Optional[str] = Field(
|
|
||||||
default=None,
|
|
||||||
description="User ID who last modified the subscription",
|
|
||||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
|
||||||
)
|
|
||||||
|
|
||||||
model_config = ConfigDict(use_enum_values=True)
|
model_config = ConfigDict(use_enum_values=True)
|
||||||
|
|
||||||
|
|
@ -100,10 +80,6 @@ registerModelLabels(
|
||||||
"description": {"en": "Description", "fr": "Description"},
|
"description": {"en": "Description", "fr": "Description"},
|
||||||
"isSystemSubscription": {"en": "System Subscription", "fr": "Abonnement système"},
|
"isSystemSubscription": {"en": "System Subscription", "fr": "Abonnement système"},
|
||||||
"enabled": {"en": "Enabled", "fr": "Activé"},
|
"enabled": {"en": "Enabled", "fr": "Activé"},
|
||||||
"creationDate": {"en": "Creation Date", "fr": "Date de création"},
|
|
||||||
"lastModified": {"en": "Last Modified", "fr": "Dernière modification"},
|
|
||||||
"createdBy": {"en": "Created By", "fr": "Créé par"},
|
|
||||||
"modifiedBy": {"en": "Modified By", "fr": "Modifié par"},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -155,16 +131,6 @@ class MessagingSubscriptionRegistration(BaseModel):
|
||||||
description="Whether this registration is enabled",
|
description="Whether this registration is enabled",
|
||||||
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
|
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
|
||||||
)
|
)
|
||||||
creationDate: float = Field(
|
|
||||||
default_factory=getUtcTimestamp,
|
|
||||||
description="When the registration was created (UTC timestamp in seconds)",
|
|
||||||
json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False}
|
|
||||||
)
|
|
||||||
lastModified: float = Field(
|
|
||||||
default_factory=getUtcTimestamp,
|
|
||||||
description="When the registration was last modified (UTC timestamp in seconds)",
|
|
||||||
json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False}
|
|
||||||
)
|
|
||||||
|
|
||||||
model_config = ConfigDict(use_enum_values=True)
|
model_config = ConfigDict(use_enum_values=True)
|
||||||
|
|
||||||
|
|
@ -181,8 +147,6 @@ registerModelLabels(
|
||||||
"channel": {"en": "Channel", "fr": "Canal"},
|
"channel": {"en": "Channel", "fr": "Canal"},
|
||||||
"channelConfig": {"en": "Channel Config", "fr": "Configuration du canal"},
|
"channelConfig": {"en": "Channel Config", "fr": "Configuration du canal"},
|
||||||
"enabled": {"en": "Enabled", "fr": "Activé"},
|
"enabled": {"en": "Enabled", "fr": "Activé"},
|
||||||
"creationDate": {"en": "Creation Date", "fr": "Date de création"},
|
|
||||||
"lastModified": {"en": "Last Modified", "fr": "Dernière modification"},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -248,11 +212,6 @@ class MessagingDelivery(BaseModel):
|
||||||
description="When the delivery was sent (UTC timestamp in seconds)",
|
description="When the delivery was sent (UTC timestamp in seconds)",
|
||||||
json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False}
|
json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False}
|
||||||
)
|
)
|
||||||
creationDate: float = Field(
|
|
||||||
default_factory=getUtcTimestamp,
|
|
||||||
description="When the delivery record was created (UTC timestamp in seconds)",
|
|
||||||
json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False}
|
|
||||||
)
|
|
||||||
|
|
||||||
model_config = ConfigDict(use_enum_values=True)
|
model_config = ConfigDict(use_enum_values=True)
|
||||||
|
|
||||||
|
|
@ -270,7 +229,6 @@ registerModelLabels(
|
||||||
"status": {"en": "Status", "fr": "Statut"},
|
"status": {"en": "Status", "fr": "Statut"},
|
||||||
"errorMessage": {"en": "Error Message", "fr": "Message d'erreur"},
|
"errorMessage": {"en": "Error Message", "fr": "Message d'erreur"},
|
||||||
"sentAt": {"en": "Sent At", "fr": "Envoyé le"},
|
"sentAt": {"en": "Sent At", "fr": "Envoyé le"},
|
||||||
"creationDate": {"en": "Creation Date", "fr": "Date de création"},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -349,4 +307,3 @@ class MessagingSubscriptionExecutionResult(BaseModel):
|
||||||
description="Error message if execution failed",
|
description="Error message if execution failed",
|
||||||
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False}
|
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False}
|
||||||
)
|
)
|
||||||
model_config = ConfigDict(extra="allow") # Allow additional fields for custom results
|
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ import uuid
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pydantic import BaseModel, Field, ConfigDict
|
from pydantic import BaseModel, Field, ConfigDict
|
||||||
|
from modules.datamodels.datamodelBase import PowerOnModel
|
||||||
from modules.shared.attributeUtils import registerModelLabels
|
from modules.shared.attributeUtils import registerModelLabels
|
||||||
from modules.shared.timeUtils import getUtcTimestamp
|
|
||||||
|
|
||||||
|
|
||||||
class NotificationType(str, Enum):
|
class NotificationType(str, Enum):
|
||||||
|
|
@ -43,7 +43,7 @@ class NotificationAction(BaseModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class UserNotification(BaseModel):
|
class UserNotification(PowerOnModel):
|
||||||
"""
|
"""
|
||||||
In-app notification for a user.
|
In-app notification for a user.
|
||||||
Supports actionable notifications with accept/decline buttons.
|
Supports actionable notifications with accept/decline buttons.
|
||||||
|
|
@ -137,11 +137,6 @@ class UserNotification(BaseModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
# Timestamps
|
# Timestamps
|
||||||
createdAt: float = Field(
|
|
||||||
default_factory=getUtcTimestamp,
|
|
||||||
description="When the notification was created (UTC timestamp)",
|
|
||||||
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
|
|
||||||
)
|
|
||||||
readAt: Optional[float] = Field(
|
readAt: Optional[float] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="When the notification was read (UTC timestamp)",
|
description="When the notification was read (UTC timestamp)",
|
||||||
|
|
@ -177,7 +172,6 @@ registerModelLabels(
|
||||||
"actions": {"en": "Actions", "de": "Aktionen", "fr": "Actions"},
|
"actions": {"en": "Actions", "de": "Aktionen", "fr": "Actions"},
|
||||||
"actionTaken": {"en": "Action Taken", "de": "Durchgeführte Aktion", "fr": "Action effectuée"},
|
"actionTaken": {"en": "Action Taken", "de": "Durchgeführte Aktion", "fr": "Action effectuée"},
|
||||||
"actionResult": {"en": "Action Result", "de": "Aktions-Ergebnis", "fr": "Résultat de l'action"},
|
"actionResult": {"en": "Action Result", "de": "Aktions-Ergebnis", "fr": "Résultat de l'action"},
|
||||||
"createdAt": {"en": "Created At", "de": "Erstellt am", "fr": "Créé le"},
|
|
||||||
"readAt": {"en": "Read At", "de": "Gelesen am", "fr": "Lu le"},
|
"readAt": {"en": "Read At", "de": "Gelesen am", "fr": "Lu le"},
|
||||||
"actionedAt": {"en": "Actioned At", "de": "Bearbeitet am", "fr": "Traité le"},
|
"actionedAt": {"en": "Actioned At", "de": "Bearbeitet am", "fr": "Traité le"},
|
||||||
"expiresAt": {"en": "Expires At", "de": "Gültig bis", "fr": "Expire le"},
|
"expiresAt": {"en": "Expires At", "de": "Gültig bis", "fr": "Expire le"},
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import uuid
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from modules.datamodels.datamodelBase import PowerOnModel
|
||||||
from modules.shared.attributeUtils import registerModelLabels
|
from modules.shared.attributeUtils import registerModelLabels
|
||||||
from modules.datamodels.datamodelUtils import TextMultilingual
|
from modules.datamodels.datamodelUtils import TextMultilingual
|
||||||
from modules.datamodels.datamodelUam import AccessLevel
|
from modules.datamodels.datamodelUam import AccessLevel
|
||||||
|
|
@ -25,7 +26,7 @@ class AccessRuleContext(str, Enum):
|
||||||
RESOURCE = "RESOURCE" # System resources (AI models, actions, etc.)
|
RESOURCE = "RESOURCE" # System resources (AI models, actions, etc.)
|
||||||
|
|
||||||
|
|
||||||
class Role(BaseModel):
|
class Role(PowerOnModel):
|
||||||
"""
|
"""
|
||||||
Data model for RBAC roles.
|
Data model for RBAC roles.
|
||||||
|
|
||||||
|
|
@ -90,7 +91,7 @@ registerModelLabels(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class AccessRule(BaseModel):
|
class AccessRule(PowerOnModel):
|
||||||
"""
|
"""
|
||||||
Data model for access control rules.
|
Data model for access control rules.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ Multi-Tenant Design:
|
||||||
|
|
||||||
from typing import Optional, Any
|
from typing import Optional, Any
|
||||||
from pydantic import BaseModel, Field, ConfigDict, model_validator
|
from pydantic import BaseModel, Field, ConfigDict, model_validator
|
||||||
|
from modules.datamodels.datamodelBase import PowerOnModel
|
||||||
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
|
||||||
|
|
@ -30,7 +31,7 @@ class TokenPurpose(str, Enum):
|
||||||
DATA_CONNECTION = "dataConnection"
|
DATA_CONNECTION = "dataConnection"
|
||||||
|
|
||||||
|
|
||||||
class Token(BaseModel):
|
class Token(PowerOnModel):
|
||||||
"""
|
"""
|
||||||
Authentication Token model.
|
Authentication Token model.
|
||||||
|
|
||||||
|
|
@ -55,9 +56,6 @@ class Token(BaseModel):
|
||||||
description="When the token expires (UTC timestamp in seconds)"
|
description="When the token expires (UTC timestamp in seconds)"
|
||||||
)
|
)
|
||||||
tokenRefresh: Optional[str] = None
|
tokenRefresh: Optional[str] = None
|
||||||
createdAt: Optional[float] = Field(
|
|
||||||
None, description="When the token was created (UTC timestamp in seconds)"
|
|
||||||
)
|
|
||||||
status: TokenStatus = Field(
|
status: TokenStatus = Field(
|
||||||
default=TokenStatus.ACTIVE, description="Token status: active/revoked"
|
default=TokenStatus.ACTIVE, description="Token status: active/revoked"
|
||||||
)
|
)
|
||||||
|
|
@ -106,7 +104,6 @@ registerModelLabels(
|
||||||
"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"},
|
||||||
"tokenRefresh": {"en": "Refresh Token", "de": "Refresh-Token", "fr": "Jeton de rafraîchissement"},
|
"tokenRefresh": {"en": "Refresh Token", "de": "Refresh-Token", "fr": "Jeton de rafraîchissement"},
|
||||||
"createdAt": {"en": "Created At", "de": "Erstellt am", "fr": "Créé le"},
|
|
||||||
"status": {"en": "Status", "de": "Status", "fr": "Statut"},
|
"status": {"en": "Status", "de": "Status", "fr": "Statut"},
|
||||||
"revokedAt": {"en": "Revoked At", "de": "Widerrufen am", "fr": "Révoqué le"},
|
"revokedAt": {"en": "Revoked At", "de": "Widerrufen am", "fr": "Révoqué le"},
|
||||||
"revokedBy": {"en": "Revoked By", "de": "Widerrufen von", "fr": "Révoqué par"},
|
"revokedBy": {"en": "Revoked By", "de": "Widerrufen von", "fr": "Révoqué par"},
|
||||||
|
|
@ -116,7 +113,7 @@ registerModelLabels(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class AuthEvent(BaseModel):
|
class AuthEvent(PowerOnModel):
|
||||||
"""Authentication event for audit logging."""
|
"""Authentication event for audit logging."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the auth event", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the auth event", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||||
userId: str = Field(description="ID of the user this event belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
userId: str = Field(description="ID of the user this event belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ from typing import Dict, List, Optional
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from modules.datamodels.datamodelBase import PowerOnModel
|
||||||
from modules.shared.attributeUtils import registerModelLabels
|
from modules.shared.attributeUtils import registerModelLabels
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
|
@ -30,6 +31,7 @@ OPERATIVE_STATUSES = {SubscriptionStatusEnum.ACTIVE, SubscriptionStatusEnum.TRIA
|
||||||
|
|
||||||
ALLOWED_TRANSITIONS = {
|
ALLOWED_TRANSITIONS = {
|
||||||
(SubscriptionStatusEnum.PENDING, SubscriptionStatusEnum.ACTIVE),
|
(SubscriptionStatusEnum.PENDING, SubscriptionStatusEnum.ACTIVE),
|
||||||
|
(SubscriptionStatusEnum.PENDING, SubscriptionStatusEnum.TRIALING),
|
||||||
(SubscriptionStatusEnum.PENDING, SubscriptionStatusEnum.SCHEDULED),
|
(SubscriptionStatusEnum.PENDING, SubscriptionStatusEnum.SCHEDULED),
|
||||||
(SubscriptionStatusEnum.PENDING, SubscriptionStatusEnum.EXPIRED),
|
(SubscriptionStatusEnum.PENDING, SubscriptionStatusEnum.EXPIRED),
|
||||||
(SubscriptionStatusEnum.SCHEDULED, SubscriptionStatusEnum.ACTIVE),
|
(SubscriptionStatusEnum.SCHEDULED, SubscriptionStatusEnum.ACTIVE),
|
||||||
|
|
@ -70,6 +72,8 @@ class SubscriptionPlan(BaseModel):
|
||||||
maxUsers: Optional[int] = Field(None, description="Hard cap on active users (None = unlimited)")
|
maxUsers: Optional[int] = Field(None, description="Hard cap on active users (None = unlimited)")
|
||||||
maxFeatureInstances: Optional[int] = Field(None, description="Hard cap on active feature instances (None = unlimited)")
|
maxFeatureInstances: Optional[int] = Field(None, description="Hard cap on active feature instances (None = unlimited)")
|
||||||
trialDays: Optional[int] = Field(None, description="Trial duration in days (only for trial plans)")
|
trialDays: Optional[int] = Field(None, description="Trial duration in days (only for trial plans)")
|
||||||
|
maxDataVolumeMB: Optional[int] = Field(None, description="Soft-limit for data volume in MB per mandate (None = unlimited)")
|
||||||
|
budgetAiCHF: float = Field(default=0.0, description="AI budget (CHF) included in subscription price per billing period")
|
||||||
successorPlanKey: Optional[str] = Field(None, description="Plan to transition to when trial ends")
|
successorPlanKey: Optional[str] = Field(None, description="Plan to transition to when trial ends")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -84,6 +88,8 @@ registerModelLabels(
|
||||||
"pricePerFeatureInstanceCHF": {"en": "Price per Instance (CHF)", "de": "Preis pro Instanz (CHF)"},
|
"pricePerFeatureInstanceCHF": {"en": "Price per Instance (CHF)", "de": "Preis pro Instanz (CHF)"},
|
||||||
"maxUsers": {"en": "Max Users", "de": "Max. Benutzer", "fr": "Max. utilisateurs"},
|
"maxUsers": {"en": "Max Users", "de": "Max. Benutzer", "fr": "Max. utilisateurs"},
|
||||||
"maxFeatureInstances": {"en": "Max Instances", "de": "Max. Instanzen", "fr": "Max. instances"},
|
"maxFeatureInstances": {"en": "Max Instances", "de": "Max. Instanzen", "fr": "Max. instances"},
|
||||||
|
"maxDataVolumeMB": {"en": "Data Volume (MB)", "de": "Datenvolumen (MB)"},
|
||||||
|
"budgetAiCHF": {"en": "AI Budget (CHF)", "de": "AI-Budget (CHF)"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -122,7 +128,7 @@ registerModelLabels(
|
||||||
# Instance: MandateSubscription
|
# Instance: MandateSubscription
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
class MandateSubscription(BaseModel):
|
class MandateSubscription(PowerOnModel):
|
||||||
"""A subscription instance bound to a specific mandate.
|
"""A subscription instance bound to a specific mandate.
|
||||||
See wiki/concepts/Subscription-State-Machine.md for state transitions."""
|
See wiki/concepts/Subscription-State-Machine.md for state transitions."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
|
||||||
|
|
@ -182,20 +188,24 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
|
||||||
autoRenew=False,
|
autoRenew=False,
|
||||||
maxUsers=None,
|
maxUsers=None,
|
||||||
maxFeatureInstances=None,
|
maxFeatureInstances=None,
|
||||||
|
maxDataVolumeMB=None,
|
||||||
|
budgetAiCHF=0.0,
|
||||||
),
|
),
|
||||||
"TRIAL_7D": SubscriptionPlan(
|
"TRIAL_7D": SubscriptionPlan(
|
||||||
planKey="TRIAL_7D",
|
planKey="TRIAL_7D",
|
||||||
selectableByUser=False,
|
selectableByUser=False,
|
||||||
title={"en": "Free Trial (7 days)", "de": "Gratis-Testphase (7 Tage)", "fr": "Essai gratuit (7 jours)"},
|
title={"en": "Free Trial (7 days)", "de": "Gratis-Testphase (7 Tage)", "fr": "Essai gratuit (7 jours)"},
|
||||||
description={
|
description={
|
||||||
"en": "Try the platform for 7 days — 1 user, up to 3 feature instances.",
|
"en": "Try the platform for 7 days — 1 user, up to 3 feature instances, 5 CHF AI budget included.",
|
||||||
"de": "Plattform 7 Tage testen — 1 User, bis zu 3 Feature-Instanzen.",
|
"de": "Plattform 7 Tage testen — 1 User, bis zu 3 Feature-Instanzen, 5 CHF AI-Budget inklusive.",
|
||||||
},
|
},
|
||||||
billingPeriod=BillingPeriodEnum.NONE,
|
billingPeriod=BillingPeriodEnum.NONE,
|
||||||
autoRenew=False,
|
autoRenew=False,
|
||||||
maxUsers=1,
|
maxUsers=1,
|
||||||
maxFeatureInstances=3,
|
maxFeatureInstances=3,
|
||||||
trialDays=7,
|
trialDays=7,
|
||||||
|
maxDataVolumeMB=500,
|
||||||
|
budgetAiCHF=5.0,
|
||||||
successorPlanKey="STANDARD_MONTHLY",
|
successorPlanKey="STANDARD_MONTHLY",
|
||||||
),
|
),
|
||||||
"STANDARD_MONTHLY": SubscriptionPlan(
|
"STANDARD_MONTHLY": SubscriptionPlan(
|
||||||
|
|
@ -203,24 +213,28 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
|
||||||
selectableByUser=True,
|
selectableByUser=True,
|
||||||
title={"en": "Standard (Monthly)", "de": "Standard (Monatlich)", "fr": "Standard (Mensuel)"},
|
title={"en": "Standard (Monthly)", "de": "Standard (Monatlich)", "fr": "Standard (Mensuel)"},
|
||||||
description={
|
description={
|
||||||
"en": "Usage-based billing per active user and feature instance, billed monthly.",
|
"en": "Usage-based billing per active user and feature instance, billed monthly. Includes 10 CHF AI budget.",
|
||||||
"de": "Nutzungsbasierte Abrechnung pro aktivem User und Feature-Instanz, monatlich.",
|
"de": "Nutzungsbasierte Abrechnung pro aktivem User und Feature-Instanz, monatlich. Inkl. 10 CHF AI-Budget.",
|
||||||
},
|
},
|
||||||
billingPeriod=BillingPeriodEnum.MONTHLY,
|
billingPeriod=BillingPeriodEnum.MONTHLY,
|
||||||
pricePerUserCHF=90.0,
|
pricePerUserCHF=90.0,
|
||||||
pricePerFeatureInstanceCHF=150.0,
|
pricePerFeatureInstanceCHF=150.0,
|
||||||
|
maxDataVolumeMB=1024,
|
||||||
|
budgetAiCHF=10.0,
|
||||||
),
|
),
|
||||||
"STANDARD_YEARLY": SubscriptionPlan(
|
"STANDARD_YEARLY": SubscriptionPlan(
|
||||||
planKey="STANDARD_YEARLY",
|
planKey="STANDARD_YEARLY",
|
||||||
selectableByUser=True,
|
selectableByUser=True,
|
||||||
title={"en": "Standard (Yearly)", "de": "Standard (Jährlich)", "fr": "Standard (Annuel)"},
|
title={"en": "Standard (Yearly)", "de": "Standard (Jährlich)", "fr": "Standard (Annuel)"},
|
||||||
description={
|
description={
|
||||||
"en": "Usage-based billing per active user and feature instance, billed yearly.",
|
"en": "Usage-based billing per active user and feature instance, billed yearly. Includes 120 CHF AI budget.",
|
||||||
"de": "Nutzungsbasierte Abrechnung pro aktivem User und Feature-Instanz, jährlich.",
|
"de": "Nutzungsbasierte Abrechnung pro aktivem User und Feature-Instanz, jährlich. Inkl. 120 CHF AI-Budget.",
|
||||||
},
|
},
|
||||||
billingPeriod=BillingPeriodEnum.YEARLY,
|
billingPeriod=BillingPeriodEnum.YEARLY,
|
||||||
pricePerUserCHF=1080.0,
|
pricePerUserCHF=1080.0,
|
||||||
pricePerFeatureInstanceCHF=1800.0,
|
pricePerFeatureInstanceCHF=1800.0,
|
||||||
|
maxDataVolumeMB=1024,
|
||||||
|
budgetAiCHF=120.0,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,10 @@ Multi-Tenant Design:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Optional, List
|
from typing import Optional, List, Dict, Any
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pydantic import BaseModel, Field, EmailStr, field_validator, computed_field
|
from pydantic import BaseModel, Field, EmailStr, field_validator, computed_field
|
||||||
|
from modules.datamodels.datamodelBase import PowerOnModel
|
||||||
from modules.shared.attributeUtils import registerModelLabels
|
from modules.shared.attributeUtils import registerModelLabels
|
||||||
from modules.shared.timeUtils import getUtcTimestamp
|
from modules.shared.timeUtils import getUtcTimestamp
|
||||||
|
|
||||||
|
|
@ -60,7 +61,7 @@ class UserPermissions(BaseModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Mandate(BaseModel):
|
class Mandate(PowerOnModel):
|
||||||
"""
|
"""
|
||||||
Mandate (Mandant/Tenant) model.
|
Mandate (Mandant/Tenant) model.
|
||||||
Ein Mandant ist ein isolierter Bereich für Daten und Berechtigungen.
|
Ein Mandant ist ein isolierter Bereich für Daten und Berechtigungen.
|
||||||
|
|
@ -89,6 +90,11 @@ class Mandate(BaseModel):
|
||||||
description="Whether this is a system mandate (e.g. root mandate). Cannot be deleted.",
|
description="Whether this is a system mandate (e.g. root mandate). Cannot be deleted.",
|
||||||
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False}
|
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False}
|
||||||
)
|
)
|
||||||
|
deletedAt: Optional[float] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Timestamp when the mandate was soft-deleted. After 30 days, hard-delete is triggered.",
|
||||||
|
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
|
||||||
|
)
|
||||||
|
|
||||||
@field_validator('isSystem', mode='before')
|
@field_validator('isSystem', mode='before')
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -98,7 +104,6 @@ class Mandate(BaseModel):
|
||||||
return False
|
return False
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
registerModelLabels(
|
registerModelLabels(
|
||||||
"Mandate",
|
"Mandate",
|
||||||
{"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
|
{"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
|
||||||
|
|
@ -108,11 +113,12 @@ registerModelLabels(
|
||||||
"label": {"en": "Label", "de": "Label", "fr": "Libellé"},
|
"label": {"en": "Label", "de": "Label", "fr": "Libellé"},
|
||||||
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
|
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
|
||||||
"isSystem": {"en": "System Mandate", "de": "System-Mandant", "fr": "Mandat système"},
|
"isSystem": {"en": "System Mandate", "de": "System-Mandant", "fr": "Mandat système"},
|
||||||
|
"deletedAt": {"en": "Deleted at", "de": "Gelöscht am", "fr": "Supprimé le"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class UserConnection(BaseModel):
|
class UserConnection(PowerOnModel):
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the connection", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the connection", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||||
userId: str = Field(description="ID of the user this connection belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
userId: str = Field(description="ID of the user this connection belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||||
authority: AuthAuthority = Field(description="Authentication authority", json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": False, "frontend_options": "/api/connections/authorities/options"})
|
authority: AuthAuthority = Field(description="Authentication authority", json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": False, "frontend_options": "/api/connections/authorities/options"})
|
||||||
|
|
@ -174,7 +180,7 @@ registerModelLabels(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class User(BaseModel):
|
class User(PowerOnModel):
|
||||||
"""
|
"""
|
||||||
User model.
|
User model.
|
||||||
|
|
||||||
|
|
@ -261,6 +267,11 @@ class User(BaseModel):
|
||||||
description="Primary authentication authority",
|
description="Primary authentication authority",
|
||||||
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": False, "frontend_options": "/api/connections/authorities/options"}
|
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": False, "frontend_options": "/api/connections/authorities/options"}
|
||||||
)
|
)
|
||||||
|
roleLabels: List[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Role labels (from DB or enriched when loading users)",
|
||||||
|
json_schema_extra={"frontend_type": "multiselect", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
registerModelLabels(
|
registerModelLabels(
|
||||||
|
|
@ -275,6 +286,7 @@ registerModelLabels(
|
||||||
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
|
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
|
||||||
"isSysAdmin": {"en": "System Admin", "de": "System-Admin", "fr": "Admin système"},
|
"isSysAdmin": {"en": "System Admin", "de": "System-Admin", "fr": "Admin système"},
|
||||||
"authenticationAuthority": {"en": "Auth Authority", "de": "Authentifizierung", "fr": "Autorité d'authentification"},
|
"authenticationAuthority": {"en": "Auth Authority", "de": "Authentifizierung", "fr": "Autorité d'authentification"},
|
||||||
|
"roleLabels": {"en": "Role Labels", "de": "Rollen-Labels", "fr": "Libellés de rôles"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -295,3 +307,65 @@ registerModelLabels(
|
||||||
"resetTokenExpires": {"en": "Reset Token Expires", "de": "Token läuft ab", "fr": "Expiration du jeton"},
|
"resetTokenExpires": {"en": "Reset Token Expires", "de": "Token läuft ab", "fr": "Expiration du jeton"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalizeTtsVoiceMap(value: Any) -> Optional[Dict[str, str]]:
|
||||||
|
"""
|
||||||
|
Coerce ttsVoiceMap payloads to Dict[str, str].
|
||||||
|
|
||||||
|
UI/clients may send per-locale objects like {"voiceName": "de-DE-Chirp3-HD-Achird"};
|
||||||
|
storage and model field type are locale -> voice id string.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return None
|
||||||
|
out: Dict[str, str] = {}
|
||||||
|
for rawKey, rawVal in value.items():
|
||||||
|
key = str(rawKey)
|
||||||
|
if rawVal is None:
|
||||||
|
continue
|
||||||
|
if isinstance(rawVal, str):
|
||||||
|
out[key] = rawVal
|
||||||
|
elif isinstance(rawVal, dict):
|
||||||
|
vn = rawVal.get("voiceName")
|
||||||
|
if vn is not None and str(vn).strip() != "":
|
||||||
|
out[key] = str(vn).strip()
|
||||||
|
else:
|
||||||
|
out[key] = str(rawVal)
|
||||||
|
return out if out else None
|
||||||
|
|
||||||
|
|
||||||
|
class UserVoicePreferences(PowerOnModel):
|
||||||
|
"""User-level voice/language preferences, shared across all features."""
|
||||||
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
|
||||||
|
userId: str = Field(description="User ID")
|
||||||
|
mandateId: Optional[str] = Field(default=None, description="Mandate scope (None = global for user)")
|
||||||
|
sttLanguage: str = Field(default="de-DE", description="Speech-to-text language code")
|
||||||
|
ttsLanguage: str = Field(default="de-DE", description="Text-to-speech language code")
|
||||||
|
ttsVoice: Optional[str] = Field(default=None, description="Preferred TTS voice identifier")
|
||||||
|
ttsVoiceMap: Optional[Dict[str, str]] = Field(default=None, description="Language-to-voice mapping")
|
||||||
|
translationSourceLanguage: Optional[str] = Field(default=None, description="Source language for translations")
|
||||||
|
translationTargetLanguage: Optional[str] = Field(default=None, description="Target language for translations")
|
||||||
|
|
||||||
|
@field_validator("ttsVoiceMap", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _validateTtsVoiceMap(cls, value: Any) -> Optional[Dict[str, str]]:
|
||||||
|
return _normalizeTtsVoiceMap(value)
|
||||||
|
|
||||||
|
|
||||||
|
registerModelLabels(
|
||||||
|
"UserVoicePreferences",
|
||||||
|
{"en": "Voice Preferences", "de": "Spracheinstellungen", "fr": "Préférences vocales"},
|
||||||
|
{
|
||||||
|
"id": {"en": "ID", "de": "ID", "fr": "ID"},
|
||||||
|
"userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
|
||||||
|
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID", "fr": "ID du mandat"},
|
||||||
|
"sttLanguage": {"en": "STT Language", "de": "STT-Sprache", "fr": "Langue STT"},
|
||||||
|
"ttsLanguage": {"en": "TTS Language", "de": "TTS-Sprache", "fr": "Langue TTS"},
|
||||||
|
"ttsVoice": {"en": "TTS Voice", "de": "TTS-Stimme", "fr": "Voix TTS"},
|
||||||
|
"ttsVoiceMap": {"en": "Voice Map", "de": "Stimmen-Zuordnung", "fr": "Carte des voix"},
|
||||||
|
"translationSourceLanguage": {"en": "Translation Source", "de": "Übersetzung Quelle", "fr": "Langue source"},
|
||||||
|
"translationTargetLanguage": {"en": "Translation Target", "de": "Übersetzung Ziel", "fr": "Langue cible"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,13 @@
|
||||||
"""Utility datamodels: Prompt, TextMultilingual."""
|
"""Utility datamodels: Prompt, TextMultilingual."""
|
||||||
|
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
from modules.datamodels.datamodelBase import PowerOnModel
|
||||||
from modules.shared.attributeUtils import registerModelLabels
|
from modules.shared.attributeUtils import registerModelLabels
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
class Prompt(BaseModel):
|
class Prompt(PowerOnModel):
|
||||||
model_config = ConfigDict(extra='allow') # Preserve system fields (_createdBy, _createdAt, etc.)
|
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||||
mandateId: str = Field(default="", description="ID of the mandate this prompt belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
mandateId: str = Field(default="", description="ID of the mandate this prompt belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||||
isSystem: bool = Field(default=False, description="System prompt visible to all users (read-only for non-SysAdmin)", json_schema_extra={"frontend_type": "boolean", "frontend_readonly": True, "frontend_required": False})
|
isSystem: bool = Field(default=False, description="System prompt visible to all users (read-only for non-SysAdmin)", json_schema_extra={"frontend_type": "boolean", "frontend_readonly": True, "frontend_required": False})
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""Voice settings datamodel — re-exported from workspace feature for backward compatibility."""
|
"""Voice settings datamodel — re-exported from UAM for central voice preferences."""
|
||||||
|
|
||||||
from modules.features.workspace.datamodelFeatureWorkspace import VoiceSettings
|
from modules.datamodels.datamodelUam import UserVoicePreferences
|
||||||
|
|
||||||
__all__ = ["VoiceSettings"]
|
__all__ = ["UserVoicePreferences"]
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from modules.datamodels.datamodelBase import PowerOnModel
|
||||||
from modules.shared.attributeUtils import registerModelLabels
|
from modules.shared.attributeUtils import registerModelLabels
|
||||||
from modules.datamodels.datamodelUtils import TextMultilingual
|
from modules.datamodels.datamodelUtils import TextMultilingual
|
||||||
import uuid
|
import uuid
|
||||||
|
|
@ -48,7 +49,7 @@ registerModelLabels(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class AutomationTemplate(BaseModel):
|
class AutomationTemplate(PowerOnModel):
|
||||||
"""Automation-Vorlage ohne scharfe Placeholder-Werte (DB-persistiert).
|
"""Automation-Vorlage ohne scharfe Placeholder-Werte (DB-persistiert).
|
||||||
|
|
||||||
System-Templates (isSystem=True): Nur durch SysAdmin aenderbar. Alle User koennen lesen.
|
System-Templates (isSystem=True): Nur durch SysAdmin aenderbar. Alle User koennen lesen.
|
||||||
|
|
@ -82,9 +83,6 @@ class AutomationTemplate(BaseModel):
|
||||||
description="Feature instance ID (null for system templates, set for instance-scoped templates)",
|
description="Feature instance ID (null for system templates, set for instance-scoped templates)",
|
||||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
||||||
)
|
)
|
||||||
# System fields (_createdAt, _createdBy, etc.) werden automatisch vom DB-Connector gesetzt
|
|
||||||
|
|
||||||
|
|
||||||
registerModelLabels(
|
registerModelLabels(
|
||||||
"AutomationTemplate",
|
"AutomationTemplate",
|
||||||
{"en": "Automation Template", "ge": "Automation-Vorlage", "fr": "Modèle d'automatisation"},
|
{"en": "Automation Template", "ge": "Automation-Vorlage", "fr": "Modèle d'automatisation"},
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,13 @@ from modules.shared.configuration import APP_CONFIG
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _automationDefinitionPayload(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Strip connector/enrichment keys; only fields defined on AutomationDefinition."""
|
||||||
|
allowed = AutomationDefinition.model_fields.keys()
|
||||||
|
return {k: v for k, v in (data or {}).items() if k in allowed}
|
||||||
|
|
||||||
|
|
||||||
# Singleton factory for Automation instances
|
# Singleton factory for Automation instances
|
||||||
_automationInterfaces = {}
|
_automationInterfaces = {}
|
||||||
|
|
||||||
|
|
@ -100,7 +107,7 @@ class AutomationObjects:
|
||||||
if recordId:
|
if recordId:
|
||||||
record = self.db.getRecordset(model, recordFilter={"id": recordId})
|
record = self.db.getRecordset(model, recordFilter={"id": recordId})
|
||||||
if record:
|
if record:
|
||||||
return record[0].get("_createdBy") == self.userId
|
return record[0].get("sysCreatedBy") == self.userId
|
||||||
else:
|
else:
|
||||||
return False # Record not found = no access
|
return False # Record not found = no access
|
||||||
return True # No recordId needed (e.g., for CREATE)
|
return True # No recordId needed (e.g., for CREATE)
|
||||||
|
|
@ -130,7 +137,7 @@ class AutomationObjects:
|
||||||
featureInstanceIds = set()
|
featureInstanceIds = set()
|
||||||
|
|
||||||
for automation in automations:
|
for automation in automations:
|
||||||
createdBy = automation.get("_createdBy")
|
createdBy = automation.get("sysCreatedBy")
|
||||||
if createdBy:
|
if createdBy:
|
||||||
userIds.add(createdBy)
|
userIds.add(createdBy)
|
||||||
|
|
||||||
|
|
@ -186,8 +193,8 @@ class AutomationObjects:
|
||||||
# Enrich each automation with the fetched data
|
# Enrich each automation with the fetched data
|
||||||
# SECURITY: Never show a fallback name — if lookup fails, show empty string
|
# SECURITY: Never show a fallback name — if lookup fails, show empty string
|
||||||
for automation in automations:
|
for automation in automations:
|
||||||
createdBy = automation.get("_createdBy")
|
createdBy = automation.get("sysCreatedBy")
|
||||||
automation["_createdByUserName"] = usersMap.get(createdBy, "") if createdBy else ""
|
automation["sysCreatedByUserName"] = usersMap.get(createdBy, "") if createdBy else ""
|
||||||
|
|
||||||
mandateId = automation.get("mandateId")
|
mandateId = automation.get("mandateId")
|
||||||
automation["mandateName"] = mandatesMap.get(mandateId, "") if mandateId else ""
|
automation["mandateName"] = mandatesMap.get(mandateId, "") if mandateId else ""
|
||||||
|
|
@ -295,7 +302,7 @@ class AutomationObjects:
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
automationId: ID of the automation to get
|
automationId: ID of the automation to get
|
||||||
includeSystemFields: If True, returns raw dict with system fields (_createdBy, etc).
|
includeSystemFields: If True, returns raw dict with system fields (sysCreatedBy, etc).
|
||||||
If False (default), returns Pydantic model without system fields.
|
If False (default), returns Pydantic model without system fields.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -330,7 +337,7 @@ class AutomationObjects:
|
||||||
return AutomationWithSystemFields(automation)
|
return AutomationWithSystemFields(automation)
|
||||||
|
|
||||||
# Clean metadata fields and return Pydantic model
|
# Clean metadata fields and return Pydantic model
|
||||||
cleanedRecord = {k: v for k, v in automation.items() if not k.startswith("_")}
|
cleanedRecord = _automationDefinitionPayload(automation)
|
||||||
return AutomationDefinition(**cleanedRecord)
|
return AutomationDefinition(**cleanedRecord)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting automation definition: {str(e)}")
|
logger.error(f"Error getting automation definition: {str(e)}")
|
||||||
|
|
@ -365,7 +372,7 @@ class AutomationObjects:
|
||||||
|
|
||||||
# Ensure database connector has correct userId context
|
# Ensure database connector has correct userId context
|
||||||
if not self.userId:
|
if not self.userId:
|
||||||
logger.error(f"createAutomationDefinition: userId is not set! Cannot set _createdBy. currentUser={self.currentUser}")
|
logger.error(f"createAutomationDefinition: userId is not set! Cannot set sysCreatedBy. currentUser={self.currentUser}")
|
||||||
elif hasattr(self.db, 'updateContext'):
|
elif hasattr(self.db, 'updateContext'):
|
||||||
try:
|
try:
|
||||||
self.db.updateContext(self.userId)
|
self.db.updateContext(self.userId)
|
||||||
|
|
@ -386,7 +393,7 @@ class AutomationObjects:
|
||||||
self._notifyAutomationChanged()
|
self._notifyAutomationChanged()
|
||||||
|
|
||||||
# Clean metadata fields and return Pydantic model
|
# Clean metadata fields and return Pydantic model
|
||||||
cleanedRecord = {k: v for k, v in createdAutomation.items() if not k.startswith("_")}
|
cleanedRecord = _automationDefinitionPayload(createdAutomation)
|
||||||
return AutomationDefinition(**cleanedRecord)
|
return AutomationDefinition(**cleanedRecord)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error creating automation definition: {str(e)}")
|
logger.error(f"Error creating automation definition: {str(e)}")
|
||||||
|
|
@ -446,7 +453,7 @@ class AutomationObjects:
|
||||||
self._notifyAutomationChanged()
|
self._notifyAutomationChanged()
|
||||||
|
|
||||||
# Clean metadata fields and return Pydantic model
|
# Clean metadata fields and return Pydantic model
|
||||||
cleanedRecord = {k: v for k, v in updatedAutomation.items() if not k.startswith("_")}
|
cleanedRecord = _automationDefinitionPayload(updatedAutomation)
|
||||||
return AutomationDefinition(**cleanedRecord)
|
return AutomationDefinition(**cleanedRecord)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error updating automation definition: {str(e)}")
|
logger.error(f"Error updating automation definition: {str(e)}")
|
||||||
|
|
@ -561,7 +568,7 @@ class AutomationObjects:
|
||||||
# Collect unique user IDs
|
# Collect unique user IDs
|
||||||
userIds = set()
|
userIds = set()
|
||||||
for template in templates:
|
for template in templates:
|
||||||
createdBy = template.get("_createdBy")
|
createdBy = template.get("sysCreatedBy")
|
||||||
if createdBy:
|
if createdBy:
|
||||||
userIds.add(createdBy)
|
userIds.add(createdBy)
|
||||||
|
|
||||||
|
|
@ -585,8 +592,8 @@ class AutomationObjects:
|
||||||
|
|
||||||
# Apply to templates — SECURITY: no fallback, empty if not found
|
# Apply to templates — SECURITY: no fallback, empty if not found
|
||||||
for template in templates:
|
for template in templates:
|
||||||
createdBy = template.get("_createdBy")
|
createdBy = template.get("sysCreatedBy")
|
||||||
template["_createdByUserName"] = userNameMap.get(createdBy, "") if createdBy else ""
|
template["sysCreatedByUserName"] = userNameMap.get(createdBy, "") if createdBy else ""
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not enrich templates with user names: {e}")
|
logger.warning(f"Could not enrich templates with user names: {e}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -227,7 +227,7 @@ def getFeatureDefinition() -> Dict[str, Any]:
|
||||||
"code": FEATURE_CODE,
|
"code": FEATURE_CODE,
|
||||||
"label": FEATURE_LABEL,
|
"label": FEATURE_LABEL,
|
||||||
"icon": FEATURE_ICON,
|
"icon": FEATURE_ICON,
|
||||||
"autoCreateInstance": True, # Automatically create instance in root mandate during bootstrap
|
"autoCreateInstance": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,8 +77,8 @@ def get_automations(
|
||||||
|
|
||||||
# If pagination was requested, result is PaginatedResult
|
# If pagination was requested, result is PaginatedResult
|
||||||
# If no pagination, result is List[Dict]
|
# If no pagination, result is List[Dict]
|
||||||
# Note: Using JSONResponse to bypass Pydantic validation which would filter out _createdBy
|
# Note: Using JSONResponse to bypass Pydantic validation which would filter out sysCreatedBy
|
||||||
# The enriched fields (_createdByUserName, mandateName) are not in the Pydantic model
|
# The enriched fields (sysCreatedByUserName, mandateName) are not in the Pydantic model
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
if paginationParams:
|
if paginationParams:
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Optional
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from modules.datamodels.datamodelBase import PowerOnModel
|
||||||
from modules.shared.attributeUtils import registerModelLabels
|
from modules.shared.attributeUtils import registerModelLabels
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
|
@ -58,7 +59,7 @@ registerModelLabels(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Automation2WorkflowRun(BaseModel):
|
class Automation2WorkflowRun(PowerOnModel):
|
||||||
id: str = Field(
|
id: str = Field(
|
||||||
default_factory=lambda: str(uuid.uuid4()),
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
description="Primary key",
|
description="Primary key",
|
||||||
|
|
@ -104,7 +105,7 @@ registerModelLabels(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Automation2HumanTask(BaseModel):
|
class Automation2HumanTask(PowerOnModel):
|
||||||
id: str = Field(
|
id: str = Field(
|
||||||
default_factory=lambda: str(uuid.uuid4()),
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
description="Primary key",
|
description="Primary key",
|
||||||
|
|
|
||||||
|
|
@ -62,12 +62,25 @@ RESOURCE_OBJECTS = [
|
||||||
]
|
]
|
||||||
|
|
||||||
TEMPLATE_ROLES = [
|
TEMPLATE_ROLES = [
|
||||||
|
{
|
||||||
|
"roleLabel": "automation2-viewer",
|
||||||
|
"description": {
|
||||||
|
"en": "Automation2 Viewer - View workflows (read-only)",
|
||||||
|
"de": "Automation2 Betrachter - Workflows ansehen (nur lesen)",
|
||||||
|
"fr": "Visualiseur Automation2 - Consulter les workflows (lecture seule)",
|
||||||
|
},
|
||||||
|
"accessRules": [
|
||||||
|
{"context": "UI", "item": "ui.feature.automation2.workflows", "view": True},
|
||||||
|
{"context": "UI", "item": "ui.feature.automation2.workflows-tasks", "view": True},
|
||||||
|
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"roleLabel": "automation2-user",
|
"roleLabel": "automation2-user",
|
||||||
"description": {
|
"description": {
|
||||||
"en": "Automation2 User - Use automation2 flow builder",
|
"en": "Automation2 User - Use automation2 flow builder",
|
||||||
"de": "Automation2 Benutzer - Flow-Builder nutzen",
|
"de": "Automation2 Benutzer - Flow-Builder nutzen",
|
||||||
"fr": "Utilisateur Automation2 - Utiliser le flow builder"
|
"fr": "Utilisateur Automation2 - Utiliser le flow builder",
|
||||||
},
|
},
|
||||||
"accessRules": [
|
"accessRules": [
|
||||||
{"context": "UI", "item": "ui.feature.automation2.editor", "view": True},
|
{"context": "UI", "item": "ui.feature.automation2.editor", "view": True},
|
||||||
|
|
@ -77,7 +90,20 @@ TEMPLATE_ROLES = [
|
||||||
{"context": "RESOURCE", "item": "resource.feature.automation2.node-types", "view": True},
|
{"context": "RESOURCE", "item": "resource.feature.automation2.node-types", "view": True},
|
||||||
{"context": "RESOURCE", "item": "resource.feature.automation2.execute", "view": True},
|
{"context": "RESOURCE", "item": "resource.feature.automation2.execute", "view": True},
|
||||||
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
|
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
|
||||||
]
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roleLabel": "automation2-admin",
|
||||||
|
"description": {
|
||||||
|
"en": "Automation2 Admin - Full UI and API for the instance; data remains user-scoped (MY)",
|
||||||
|
"de": "Automation2 Admin - Volle UI und API für die Instanz; Daten weiterhin benutzerspezifisch (MY)",
|
||||||
|
"fr": "Administrateur Automation2 - UI et API complets pour l'instance; donnees limitees a l'utilisateur (MY)",
|
||||||
|
},
|
||||||
|
"accessRules": [
|
||||||
|
{"context": "UI", "item": None, "view": True},
|
||||||
|
{"context": "RESOURCE", "item": None, "view": True},
|
||||||
|
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -179,7 +205,7 @@ def getFeatureDefinition() -> Dict[str, Any]:
|
||||||
"code": FEATURE_CODE,
|
"code": FEATURE_CODE,
|
||||||
"label": FEATURE_LABEL,
|
"label": FEATURE_LABEL,
|
||||||
"icon": FEATURE_ICON,
|
"icon": FEATURE_ICON,
|
||||||
"autoCreateInstance": True,
|
"autoCreateInstance": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -459,7 +459,7 @@ def get_workflows(
|
||||||
active_run = None
|
active_run = None
|
||||||
last_started_at = None
|
last_started_at = None
|
||||||
for r in runs:
|
for r in runs:
|
||||||
ts = r.get("_createdAt")
|
ts = r.get("sysCreatedAt")
|
||||||
if ts and (last_started_at is None or ts > last_started_at):
|
if ts and (last_started_at is None or ts > last_started_at):
|
||||||
last_started_at = ts
|
last_started_at = ts
|
||||||
if r.get("status") in ("running", "paused"):
|
if r.get("status") in ("running", "paused"):
|
||||||
|
|
@ -475,7 +475,7 @@ def get_workflows(
|
||||||
"runStatus": active_run.get("status") if active_run else None,
|
"runStatus": active_run.get("status") if active_run else None,
|
||||||
"stuckAtNodeId": stuck_at_node_id,
|
"stuckAtNodeId": stuck_at_node_id,
|
||||||
"stuckAtNodeLabel": stuck_at_node_label or stuck_at_node_id or "",
|
"stuckAtNodeLabel": stuck_at_node_label or stuck_at_node_id or "",
|
||||||
"createdAt": wf.get("_createdAt"),
|
"createdAt": wf.get("sysCreatedAt"),
|
||||||
"lastStartedAt": last_started_at,
|
"lastStartedAt": last_started_at,
|
||||||
})
|
})
|
||||||
return {"workflows": enriched}
|
return {"workflows": enriched}
|
||||||
|
|
@ -788,7 +788,7 @@ def get_tasks(
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Get tasks - by default those assigned to current user, or all if no assignee filter.
|
"""Get tasks - by default those assigned to current user, or all if no assignee filter.
|
||||||
Enriches each task with workflowLabel and createdAt (_createdAt).
|
Enriches each task with workflowLabel and createdAt (from sysCreatedAt).
|
||||||
"""
|
"""
|
||||||
mandateId = _validateInstanceAccess(instanceId, context)
|
mandateId = _validateInstanceAccess(instanceId, context)
|
||||||
a2 = getAutomation2Interface(context.user, mandateId, instanceId)
|
a2 = getAutomation2Interface(context.user, mandateId, instanceId)
|
||||||
|
|
@ -801,7 +801,7 @@ def get_tasks(
|
||||||
enriched.append({
|
enriched.append({
|
||||||
**t,
|
**t,
|
||||||
"workflowLabel": wf.get("label", t.get("workflowId", "")) if wf else t.get("workflowId", ""),
|
"workflowLabel": wf.get("label", t.get("workflowId", "")) if wf else t.get("workflowId", ""),
|
||||||
"createdAt": t.get("_createdAt"),
|
"createdAt": t.get("sysCreatedAt"),
|
||||||
})
|
})
|
||||||
return {"tasks": enriched}
|
return {"tasks": enriched}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ from modules.datamodels.datamodelRbac import AccessRuleContext
|
||||||
from modules.datamodels.datamodelUam import AccessLevel
|
from modules.datamodels.datamodelUam import AccessLevel
|
||||||
|
|
||||||
from modules.datamodels.datamodelChat import UserInputRequest
|
from modules.datamodels.datamodelChat import UserInputRequest
|
||||||
|
from modules.datamodels.datamodelBase import PowerOnModel
|
||||||
from modules.shared.timeUtils import getUtcTimestamp, parseTimestamp
|
from modules.shared.timeUtils import getUtcTimestamp, parseTimestamp
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -27,7 +28,7 @@ from modules.shared.timeUtils import getUtcTimestamp, parseTimestamp
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class ChatbotDocument(BaseModel):
|
class ChatbotDocument(PowerOnModel):
|
||||||
"""Documents attached to chatbot messages."""
|
"""Documents attached to chatbot messages."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
|
||||||
messageId: str = Field(description="Foreign key to message")
|
messageId: str = Field(description="Foreign key to message")
|
||||||
|
|
@ -41,7 +42,7 @@ class ChatbotDocument(BaseModel):
|
||||||
actionId: Optional[str] = Field(None, description="ID of the action that created this document")
|
actionId: Optional[str] = Field(None, description="ID of the action that created this document")
|
||||||
|
|
||||||
|
|
||||||
class ChatbotMessage(BaseModel):
|
class ChatbotMessage(PowerOnModel):
|
||||||
"""Messages in chatbot conversations. Must match bridge format in memory.py."""
|
"""Messages in chatbot conversations. Must match bridge format in memory.py."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
|
||||||
conversationId: str = Field(description="Foreign key to conversation")
|
conversationId: str = Field(description="Foreign key to conversation")
|
||||||
|
|
@ -64,7 +65,7 @@ class ChatbotMessage(BaseModel):
|
||||||
actionProgress: Optional[str] = Field(None, description="Action progress status")
|
actionProgress: Optional[str] = Field(None, description="Action progress status")
|
||||||
|
|
||||||
|
|
||||||
class ChatbotLog(BaseModel):
|
class ChatbotLog(PowerOnModel):
|
||||||
"""Log entries for chatbot conversations."""
|
"""Log entries for chatbot conversations."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
|
||||||
conversationId: str = Field(description="Foreign key to conversation")
|
conversationId: str = Field(description="Foreign key to conversation")
|
||||||
|
|
@ -85,7 +86,7 @@ class ChatbotWorkflowModeEnum(str, Enum):
|
||||||
WORKFLOW_CHATBOT = "Chatbot"
|
WORKFLOW_CHATBOT = "Chatbot"
|
||||||
|
|
||||||
|
|
||||||
class ChatbotConversation(BaseModel):
|
class ChatbotConversation(PowerOnModel):
|
||||||
"""Chatbot conversation container. Per feature-instance isolation via featureInstanceId."""
|
"""Chatbot conversation container. Per feature-instance isolation via featureInstanceId."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
|
||||||
featureInstanceId: str = Field(description="Feature instance ID for per-instance isolation")
|
featureInstanceId: str = Field(description="Feature instance ID for per-instance isolation")
|
||||||
|
|
@ -328,9 +329,8 @@ class ChatObjects:
|
||||||
objectFields[fieldName] = value
|
objectFields[fieldName] = value
|
||||||
else:
|
else:
|
||||||
# Field not in model - treat as scalar if simple, otherwise filter out
|
# Field not in model - treat as scalar if simple, otherwise filter out
|
||||||
# BUT: always include metadata fields (_createdBy, _createdAt, etc.) as they're handled by connector
|
# Underscore-prefixed keys (e.g. UI meta) pass through; sys* live on PowerOnModel subclasses
|
||||||
if fieldName.startswith("_"):
|
if fieldName.startswith("_"):
|
||||||
# Metadata fields should be passed through to connector
|
|
||||||
simpleFields[fieldName] = value
|
simpleFields[fieldName] = value
|
||||||
elif isinstance(value, (str, int, float, bool, type(None))):
|
elif isinstance(value, (str, int, float, bool, type(None))):
|
||||||
simpleFields[fieldName] = value
|
simpleFields[fieldName] = value
|
||||||
|
|
|
||||||
|
|
@ -1222,23 +1222,21 @@ def _preflight_billing_check(services, mandateId: str, featureInstanceId: Option
|
||||||
balanceCheck = billingService.checkBalance(0.01)
|
balanceCheck = billingService.checkBalance(0.01)
|
||||||
if not balanceCheck.allowed:
|
if not balanceCheck.allowed:
|
||||||
mid = str(getattr(services, "mandateId", None) or mandateId or "")
|
mid = str(getattr(services, "mandateId", None) or mandateId or "")
|
||||||
from modules.datamodels.datamodelBilling import BillingModelEnum
|
|
||||||
from modules.serviceCenter.services.serviceBilling.billingExhaustedNotify import (
|
from modules.serviceCenter.services.serviceBilling.billingExhaustedNotify import (
|
||||||
maybeEmailMandatePoolExhausted,
|
maybeEmailMandatePoolExhausted,
|
||||||
)
|
)
|
||||||
if balanceCheck.billingModel == BillingModelEnum.PREPAY_MANDATE:
|
u = getattr(services, "user", None)
|
||||||
u = getattr(services, "user", None)
|
ulabel = (
|
||||||
ulabel = (
|
(getattr(u, "email", None) or getattr(u, "username", None) or str(getattr(u, "id", "")))
|
||||||
(getattr(u, "email", None) or getattr(u, "username", None) or str(getattr(u, "id", "")))
|
if u is not None else ""
|
||||||
if u is not None else ""
|
)
|
||||||
)
|
maybeEmailMandatePoolExhausted(
|
||||||
maybeEmailMandatePoolExhausted(
|
mid,
|
||||||
mid,
|
str(getattr(u, "id", "") if u is not None else ""),
|
||||||
str(getattr(u, "id", "") if u is not None else ""),
|
ulabel,
|
||||||
ulabel,
|
float(balanceCheck.currentBalance or 0.0),
|
||||||
float(balanceCheck.currentBalance or 0.0),
|
0.01,
|
||||||
0.01,
|
)
|
||||||
)
|
|
||||||
raise BillingService.InsufficientBalanceException.fromBalanceCheck(
|
raise BillingService.InsufficientBalanceException.fromBalanceCheck(
|
||||||
balanceCheck,
|
balanceCheck,
|
||||||
mid,
|
mid,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ Pydantic models for coaching contexts, sessions, messages, tasks, scores, and us
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List, Dict, Any
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
|
from modules.datamodels.datamodelBase import PowerOnModel
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -73,7 +75,7 @@ class CoachingScoreTrend(str, Enum):
|
||||||
# Database Models
|
# Database Models
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
class CoachingContext(BaseModel):
|
class CoachingContext(PowerOnModel):
|
||||||
"""A coaching context/dossier representing a topic the user is working on."""
|
"""A coaching context/dossier representing a topic the user is working on."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
userId: str = Field(description="Owner user ID (strict ownership)")
|
userId: str = Field(description="Owner user ID (strict ownership)")
|
||||||
|
|
@ -91,11 +93,9 @@ class CoachingContext(BaseModel):
|
||||||
lastSessionAt: Optional[str] = Field(default=None)
|
lastSessionAt: Optional[str] = Field(default=None)
|
||||||
rollingOverview: Optional[str] = Field(default=None, description="AI summary of older sessions for long context history")
|
rollingOverview: Optional[str] = Field(default=None, description="AI summary of older sessions for long context history")
|
||||||
rollingOverviewUpToSessionCount: Optional[int] = Field(default=None, description="Session count covered by rollingOverview")
|
rollingOverviewUpToSessionCount: Optional[int] = Field(default=None, description="Session count covered by rollingOverview")
|
||||||
createdAt: Optional[str] = Field(default=None)
|
|
||||||
updatedAt: Optional[str] = Field(default=None)
|
|
||||||
|
|
||||||
|
|
||||||
class CoachingSession(BaseModel):
|
class CoachingSession(PowerOnModel):
|
||||||
"""A single coaching conversation session within a context."""
|
"""A single coaching conversation session within a context."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
contextId: str = Field(description="FK to CoachingContext")
|
contextId: str = Field(description="FK to CoachingContext")
|
||||||
|
|
@ -115,11 +115,9 @@ class CoachingSession(BaseModel):
|
||||||
emailSent: bool = Field(default=False)
|
emailSent: bool = Field(default=False)
|
||||||
startedAt: Optional[str] = Field(default=None)
|
startedAt: Optional[str] = Field(default=None)
|
||||||
endedAt: Optional[str] = Field(default=None)
|
endedAt: Optional[str] = Field(default=None)
|
||||||
createdAt: Optional[str] = Field(default=None)
|
|
||||||
updatedAt: Optional[str] = Field(default=None)
|
|
||||||
|
|
||||||
|
|
||||||
class CoachingMessage(BaseModel):
|
class CoachingMessage(PowerOnModel):
|
||||||
"""A single message in a coaching session."""
|
"""A single message in a coaching session."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
sessionId: str = Field(description="FK to CoachingSession")
|
sessionId: str = Field(description="FK to CoachingSession")
|
||||||
|
|
@ -130,10 +128,9 @@ class CoachingMessage(BaseModel):
|
||||||
contentType: CoachingMessageContentType = Field(default=CoachingMessageContentType.TEXT)
|
contentType: CoachingMessageContentType = Field(default=CoachingMessageContentType.TEXT)
|
||||||
audioRef: Optional[str] = Field(default=None, description="Reference to audio file")
|
audioRef: Optional[str] = Field(default=None, description="Reference to audio file")
|
||||||
metadata: Optional[str] = Field(default=None, description="JSON: token count, voice info, etc.")
|
metadata: Optional[str] = Field(default=None, description="JSON: token count, voice info, etc.")
|
||||||
createdAt: Optional[str] = Field(default=None)
|
|
||||||
|
|
||||||
|
|
||||||
class CoachingTask(BaseModel):
|
class CoachingTask(PowerOnModel):
|
||||||
"""A task/checklist item assigned within a coaching context."""
|
"""A task/checklist item assigned within a coaching context."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
contextId: str = Field(description="FK to CoachingContext")
|
contextId: str = Field(description="FK to CoachingContext")
|
||||||
|
|
@ -146,11 +143,9 @@ class CoachingTask(BaseModel):
|
||||||
priority: CoachingTaskPriority = Field(default=CoachingTaskPriority.MEDIUM)
|
priority: CoachingTaskPriority = Field(default=CoachingTaskPriority.MEDIUM)
|
||||||
dueDate: Optional[str] = Field(default=None)
|
dueDate: Optional[str] = Field(default=None)
|
||||||
completedAt: Optional[str] = Field(default=None)
|
completedAt: Optional[str] = Field(default=None)
|
||||||
createdAt: Optional[str] = Field(default=None)
|
|
||||||
updatedAt: Optional[str] = Field(default=None)
|
|
||||||
|
|
||||||
|
|
||||||
class CoachingScore(BaseModel):
|
class CoachingScore(PowerOnModel):
|
||||||
"""A competence score for a dimension, recorded after a session."""
|
"""A competence score for a dimension, recorded after a session."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
contextId: str = Field(description="FK to CoachingContext")
|
contextId: str = Field(description="FK to CoachingContext")
|
||||||
|
|
@ -161,17 +156,14 @@ class CoachingScore(BaseModel):
|
||||||
score: float = Field(ge=0.0, le=100.0)
|
score: float = Field(ge=0.0, le=100.0)
|
||||||
trend: CoachingScoreTrend = Field(default=CoachingScoreTrend.STABLE)
|
trend: CoachingScoreTrend = Field(default=CoachingScoreTrend.STABLE)
|
||||||
evidence: Optional[str] = Field(default=None, description="AI reasoning for the score")
|
evidence: Optional[str] = Field(default=None, description="AI reasoning for the score")
|
||||||
createdAt: Optional[str] = Field(default=None)
|
|
||||||
|
|
||||||
|
|
||||||
class CoachingUserProfile(BaseModel):
|
class CoachingUserProfile(PowerOnModel):
|
||||||
"""Per-user coaching profile and preferences."""
|
"""Per-user coaching profile and preferences."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
userId: str = Field(description="Owner user ID")
|
userId: str = Field(description="Owner user ID")
|
||||||
mandateId: str = Field(description="Mandate ID")
|
mandateId: str = Field(description="Mandate ID")
|
||||||
instanceId: str = Field(description="Feature instance ID")
|
instanceId: str = Field(description="Feature instance ID")
|
||||||
preferredLanguage: str = Field(default="de-DE")
|
|
||||||
preferredVoice: Optional[str] = Field(default=None, description="Google TTS voice name")
|
|
||||||
dailyReminderTime: Optional[str] = Field(default=None, description="HH:MM format")
|
dailyReminderTime: Optional[str] = Field(default=None, description="HH:MM format")
|
||||||
dailyReminderEnabled: bool = Field(default=False)
|
dailyReminderEnabled: bool = Field(default=False)
|
||||||
emailSummaryEnabled: bool = Field(default=True)
|
emailSummaryEnabled: bool = Field(default=True)
|
||||||
|
|
@ -180,15 +172,13 @@ class CoachingUserProfile(BaseModel):
|
||||||
totalSessions: int = Field(default=0)
|
totalSessions: int = Field(default=0)
|
||||||
totalMinutes: int = Field(default=0)
|
totalMinutes: int = Field(default=0)
|
||||||
lastSessionAt: Optional[str] = Field(default=None)
|
lastSessionAt: Optional[str] = Field(default=None)
|
||||||
createdAt: Optional[str] = Field(default=None)
|
|
||||||
updatedAt: Optional[str] = Field(default=None)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Iteration 2: Personas
|
# Iteration 2: Personas
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
class CoachingPersona(BaseModel):
|
class CoachingPersona(PowerOnModel):
|
||||||
"""A roleplay persona for coaching sessions."""
|
"""A roleplay persona for coaching sessions."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
userId: str = Field(description="Owner user ID ('system' for builtins)")
|
userId: str = Field(description="Owner user ID ('system' for builtins)")
|
||||||
|
|
@ -201,35 +191,13 @@ class CoachingPersona(BaseModel):
|
||||||
gender: Optional[str] = Field(default=None, description="m or f")
|
gender: Optional[str] = Field(default=None, description="m or f")
|
||||||
category: str = Field(default="builtin", description="'builtin' or 'custom'")
|
category: str = Field(default="builtin", description="'builtin' or 'custom'")
|
||||||
isActive: bool = Field(default=True)
|
isActive: bool = Field(default=True)
|
||||||
createdAt: Optional[str] = Field(default=None)
|
|
||||||
updatedAt: Optional[str] = Field(default=None)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Iteration 2: Documents
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
class CoachingDocument(BaseModel):
|
|
||||||
"""A document attached to a coaching context."""
|
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
||||||
contextId: str = Field(description="FK to CoachingContext")
|
|
||||||
userId: str = Field(description="Owner user ID")
|
|
||||||
mandateId: str = Field(description="Mandate ID")
|
|
||||||
instanceId: Optional[str] = Field(default=None)
|
|
||||||
fileName: str = Field(description="Original file name")
|
|
||||||
mimeType: str = Field(default="application/octet-stream")
|
|
||||||
fileSize: int = Field(default=0)
|
|
||||||
extractedText: Optional[str] = Field(default=None, description="Text content extracted from file")
|
|
||||||
summary: Optional[str] = Field(default=None, description="AI-generated summary")
|
|
||||||
fileRef: Optional[str] = Field(default=None, description="Reference to file in storage")
|
|
||||||
createdAt: Optional[str] = Field(default=None)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Iteration 2: Badges / Gamification
|
# Iteration 2: Badges / Gamification
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
class CoachingBadge(BaseModel):
|
class CoachingBadge(PowerOnModel):
|
||||||
"""An achievement badge awarded to a user."""
|
"""An achievement badge awarded to a user."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
userId: str = Field(description="Owner user ID")
|
userId: str = Field(description="Owner user ID")
|
||||||
|
|
@ -237,7 +205,6 @@ class CoachingBadge(BaseModel):
|
||||||
instanceId: str = Field(description="Feature instance ID")
|
instanceId: str = Field(description="Feature instance ID")
|
||||||
badgeKey: str = Field(description="Badge identifier, e.g. 'streak_7'")
|
badgeKey: str = Field(description="Badge identifier, e.g. 'streak_7'")
|
||||||
awardedAt: Optional[str] = Field(default=None)
|
awardedAt: Optional[str] = Field(default=None)
|
||||||
createdAt: Optional[str] = Field(default=None)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
@ -282,8 +249,6 @@ class UpdateTaskStatusRequest(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class UpdateProfileRequest(BaseModel):
|
class UpdateProfileRequest(BaseModel):
|
||||||
preferredLanguage: Optional[str] = None
|
|
||||||
preferredVoice: Optional[str] = None
|
|
||||||
dailyReminderTime: Optional[str] = None
|
dailyReminderTime: Optional[str] = None
|
||||||
dailyReminderEnabled: Optional[bool] = None
|
dailyReminderEnabled: Optional[bool] = None
|
||||||
emailSummaryEnabled: Optional[bool] = None
|
emailSummaryEnabled: Optional[bool] = None
|
||||||
|
|
|
||||||
|
|
@ -269,34 +269,6 @@ class CommcoachObjects:
|
||||||
from .datamodelCommcoach import CoachingPersona
|
from .datamodelCommcoach import CoachingPersona
|
||||||
return self.db.recordDelete(CoachingPersona, personaId)
|
return self.db.recordDelete(CoachingPersona, personaId)
|
||||||
|
|
||||||
# =========================================================================
|
|
||||||
# Documents
|
|
||||||
# =========================================================================
|
|
||||||
|
|
||||||
def getDocuments(self, contextId: str, userId: str) -> List[Dict[str, Any]]:
|
|
||||||
from .datamodelCommcoach import CoachingDocument
|
|
||||||
records = self.db.getRecordset(CoachingDocument, recordFilter={"contextId": contextId, "userId": userId})
|
|
||||||
records.sort(key=lambda r: r.get("createdAt") or "", reverse=True)
|
|
||||||
return records
|
|
||||||
|
|
||||||
def getDocument(self, documentId: str) -> Optional[Dict[str, Any]]:
|
|
||||||
from .datamodelCommcoach import CoachingDocument
|
|
||||||
records = self.db.getRecordset(CoachingDocument, recordFilter={"id": documentId})
|
|
||||||
return records[0] if records else None
|
|
||||||
|
|
||||||
def createDocument(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
from .datamodelCommcoach import CoachingDocument
|
|
||||||
data["createdAt"] = getIsoTimestamp()
|
|
||||||
return self.db.recordCreate(CoachingDocument, data)
|
|
||||||
|
|
||||||
def updateDocument(self, documentId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
||||||
from .datamodelCommcoach import CoachingDocument
|
|
||||||
return self.db.recordModify(CoachingDocument, documentId, updates)
|
|
||||||
|
|
||||||
def deleteDocument(self, documentId: str) -> bool:
|
|
||||||
from .datamodelCommcoach import CoachingDocument
|
|
||||||
return self.db.recordDelete(CoachingDocument, documentId)
|
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Badges
|
# Badges
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -61,18 +61,13 @@ DATA_OBJECTS = [
|
||||||
{
|
{
|
||||||
"objectKey": "data.feature.commcoach.CoachingUserProfile",
|
"objectKey": "data.feature.commcoach.CoachingUserProfile",
|
||||||
"label": {"en": "User Profile", "de": "Benutzerprofil", "fr": "Profil utilisateur"},
|
"label": {"en": "User Profile", "de": "Benutzerprofil", "fr": "Profil utilisateur"},
|
||||||
"meta": {"table": "CoachingUserProfile", "fields": ["id", "userId", "preferredLanguage"]}
|
"meta": {"table": "CoachingUserProfile", "fields": ["id", "userId", "dailyReminderEnabled"]}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"objectKey": "data.feature.commcoach.CoachingPersona",
|
"objectKey": "data.feature.commcoach.CoachingPersona",
|
||||||
"label": {"en": "Coaching Persona", "de": "Coaching-Persona", "fr": "Persona coaching"},
|
"label": {"en": "Coaching Persona", "de": "Coaching-Persona", "fr": "Persona coaching"},
|
||||||
"meta": {"table": "CoachingPersona", "fields": ["id", "key", "label", "gender"]}
|
"meta": {"table": "CoachingPersona", "fields": ["id", "key", "label", "gender"]}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"objectKey": "data.feature.commcoach.CoachingDocument",
|
|
||||||
"label": {"en": "Coaching Document", "de": "Coaching-Dokument", "fr": "Document coaching"},
|
|
||||||
"meta": {"table": "CoachingDocument", "fields": ["id", "contextId", "fileName"]}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"objectKey": "data.feature.commcoach.CoachingBadge",
|
"objectKey": "data.feature.commcoach.CoachingBadge",
|
||||||
"label": {"en": "Coaching Badge", "de": "Coaching-Auszeichnung", "fr": "Badge coaching"},
|
"label": {"en": "Coaching Badge", "de": "Coaching-Auszeichnung", "fr": "Badge coaching"},
|
||||||
|
|
@ -114,12 +109,27 @@ RESOURCE_OBJECTS = [
|
||||||
]
|
]
|
||||||
|
|
||||||
TEMPLATE_ROLES = [
|
TEMPLATE_ROLES = [
|
||||||
|
{
|
||||||
|
"roleLabel": "commcoach-viewer",
|
||||||
|
"description": {
|
||||||
|
"en": "Communication Coach Viewer - View coaching data (read-only)",
|
||||||
|
"de": "Kommunikations-Coach Betrachter - Coaching-Daten ansehen (nur lesen)",
|
||||||
|
"fr": "Visualiseur Coach Communication - Consulter les donnees coaching (lecture seule)",
|
||||||
|
},
|
||||||
|
"accessRules": [
|
||||||
|
{"context": "UI", "item": "ui.feature.commcoach.dashboard", "view": True},
|
||||||
|
{"context": "UI", "item": "ui.feature.commcoach.coaching", "view": True},
|
||||||
|
{"context": "UI", "item": "ui.feature.commcoach.dossier", "view": True},
|
||||||
|
{"context": "UI", "item": "ui.feature.commcoach.settings", "view": True},
|
||||||
|
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"roleLabel": "commcoach-user",
|
"roleLabel": "commcoach-user",
|
||||||
"description": {
|
"description": {
|
||||||
"en": "Communication Coach User - Can manage own coaching contexts and sessions",
|
"en": "Communication Coach User - Can manage own coaching contexts and sessions",
|
||||||
"de": "Kommunikations-Coach Benutzer - Kann eigene Coaching-Kontexte und Sessions verwalten",
|
"de": "Kommunikations-Coach Benutzer - Kann eigene Coaching-Kontexte und Sessions verwalten",
|
||||||
"fr": "Utilisateur Coach Communication - Peut gerer ses propres contextes et sessions"
|
"fr": "Utilisateur Coach Communication - Peut gerer ses propres contextes et sessions",
|
||||||
},
|
},
|
||||||
"accessRules": [
|
"accessRules": [
|
||||||
{"context": "UI", "item": "ui.feature.commcoach.dashboard", "view": True},
|
{"context": "UI", "item": "ui.feature.commcoach.dashboard", "view": True},
|
||||||
|
|
@ -137,7 +147,20 @@ TEMPLATE_ROLES = [
|
||||||
{"context": "RESOURCE", "item": "resource.feature.commcoach.session.start", "view": True},
|
{"context": "RESOURCE", "item": "resource.feature.commcoach.session.start", "view": True},
|
||||||
{"context": "RESOURCE", "item": "resource.feature.commcoach.session.complete", "view": True},
|
{"context": "RESOURCE", "item": "resource.feature.commcoach.session.complete", "view": True},
|
||||||
{"context": "RESOURCE", "item": "resource.feature.commcoach.task.manage", "view": True},
|
{"context": "RESOURCE", "item": "resource.feature.commcoach.task.manage", "view": True},
|
||||||
]
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roleLabel": "commcoach-admin",
|
||||||
|
"description": {
|
||||||
|
"en": "Communication Coach Admin - All UI and API actions; data scoped to own records",
|
||||||
|
"de": "Kommunikations-Coach Admin - Alle UI- und API-Aktionen; Daten nur eigene Datensaetze",
|
||||||
|
"fr": "Administrateur Coach Communication - Toute l'UI et les API; donnees propres",
|
||||||
|
},
|
||||||
|
"accessRules": [
|
||||||
|
{"context": "UI", "item": None, "view": True},
|
||||||
|
{"context": "RESOURCE", "item": None, "view": True},
|
||||||
|
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -147,7 +170,7 @@ def getFeatureDefinition() -> Dict[str, Any]:
|
||||||
"code": FEATURE_CODE,
|
"code": FEATURE_CODE,
|
||||||
"label": FEATURE_LABEL,
|
"label": FEATURE_LABEL,
|
||||||
"icon": FEATURE_ICON,
|
"icon": FEATURE_ICON,
|
||||||
"autoCreateInstance": True,
|
"autoCreateInstance": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
CommCoach routes for the backend API.
|
CommCoach routes for the backend API.
|
||||||
Implements coaching context management, session streaming, tasks, dashboard, and voice endpoints.
|
Implements coaching context management, session streaming, tasks, and dashboard.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -26,7 +26,7 @@ from .datamodelCommcoach import (
|
||||||
CoachingContext, CoachingContextStatus, CoachingSession, CoachingSessionStatus,
|
CoachingContext, CoachingContextStatus, CoachingSession, CoachingSessionStatus,
|
||||||
CoachingMessage, CoachingMessageRole, CoachingMessageContentType,
|
CoachingMessage, CoachingMessageRole, CoachingMessageContentType,
|
||||||
CoachingTask, CoachingTaskStatus,
|
CoachingTask, CoachingTaskStatus,
|
||||||
CoachingPersona, CoachingDocument, CoachingBadge,
|
CoachingPersona, CoachingBadge,
|
||||||
CreateContextRequest, UpdateContextRequest,
|
CreateContextRequest, UpdateContextRequest,
|
||||||
SendMessageRequest, CreateTaskRequest, UpdateTaskRequest, UpdateTaskStatusRequest,
|
SendMessageRequest, CreateTaskRequest, UpdateTaskRequest, UpdateTaskStatusRequest,
|
||||||
UpdateProfileRequest,
|
UpdateProfileRequest,
|
||||||
|
|
@ -334,9 +334,8 @@ async def startSession(
|
||||||
try:
|
try:
|
||||||
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
|
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
|
||||||
voiceInterface = getVoiceInterface(context.user, mandateId)
|
voiceInterface = getVoiceInterface(context.user, mandateId)
|
||||||
profile = interface.getProfile(userId, instanceId)
|
from .serviceCommcoach import _getUserVoicePrefs
|
||||||
language = profile.get("preferredLanguage", "de-DE") if profile else "de-DE"
|
language, voiceName = _getUserVoicePrefs(userId, mandateId)
|
||||||
voiceName = profile.get("preferredVoice") if profile else None
|
|
||||||
from .serviceCommcoach import _stripMarkdownForTts
|
from .serviceCommcoach import _stripMarkdownForTts
|
||||||
ttsResult = await voiceInterface.textToSpeech(
|
ttsResult = await voiceInterface.textToSpeech(
|
||||||
text=_stripMarkdownForTts(greetingText),
|
text=_stripMarkdownForTts(greetingText),
|
||||||
|
|
@ -574,8 +573,8 @@ async def sendAudioStream(
|
||||||
if not audioBody:
|
if not audioBody:
|
||||||
raise HTTPException(status_code=400, detail="No audio data received")
|
raise HTTPException(status_code=400, detail="No audio data received")
|
||||||
|
|
||||||
profile = interface.getProfile(str(context.user.id), instanceId)
|
from .serviceCommcoach import _getUserVoicePrefs
|
||||||
language = profile.get("preferredLanguage", "de-DE") if profile else "de-DE"
|
language, _ = _getUserVoicePrefs(str(context.user.id), mandateId)
|
||||||
|
|
||||||
contextId = session.get("contextId")
|
contextId = session.get("contextId")
|
||||||
service = CommcoachService(context.user, mandateId, instanceId)
|
service = CommcoachService(context.user, mandateId, instanceId)
|
||||||
|
|
@ -839,73 +838,6 @@ async def updateProfile(
|
||||||
return {"profile": updated}
|
return {"profile": updated}
|
||||||
|
|
||||||
|
|
||||||
# =========================================================================
|
|
||||||
# Voice Endpoints
|
|
||||||
# =========================================================================
|
|
||||||
|
|
||||||
@router.get("/{instanceId}/voice/languages")
|
|
||||||
@limiter.limit("30/minute")
|
|
||||||
async def getVoiceLanguages(
|
|
||||||
request: Request,
|
|
||||||
instanceId: str,
|
|
||||||
context: RequestContext = Depends(getRequestContext),
|
|
||||||
):
|
|
||||||
mandateId = _validateInstanceAccess(instanceId, context)
|
|
||||||
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
|
|
||||||
voiceInterface = getVoiceInterface(context.user, mandateId)
|
|
||||||
languagesResult = await voiceInterface.getAvailableLanguages()
|
|
||||||
languageList = languagesResult.get("languages", []) if isinstance(languagesResult, dict) else languagesResult
|
|
||||||
return {"languages": languageList}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{instanceId}/voice/voices")
|
|
||||||
@limiter.limit("30/minute")
|
|
||||||
async def getVoiceVoices(
|
|
||||||
request: Request,
|
|
||||||
instanceId: str,
|
|
||||||
language: str = "de-DE",
|
|
||||||
context: RequestContext = Depends(getRequestContext),
|
|
||||||
):
|
|
||||||
mandateId = _validateInstanceAccess(instanceId, context)
|
|
||||||
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
|
|
||||||
voiceInterface = getVoiceInterface(context.user, mandateId)
|
|
||||||
voicesResult = await voiceInterface.getAvailableVoices(language)
|
|
||||||
voiceList = voicesResult.get("voices", []) if isinstance(voicesResult, dict) else voicesResult
|
|
||||||
return {"voices": voiceList}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{instanceId}/voice/tts")
|
|
||||||
@limiter.limit("10/minute")
|
|
||||||
async def testVoice(
|
|
||||||
request: Request,
|
|
||||||
instanceId: str,
|
|
||||||
context: RequestContext = Depends(getRequestContext),
|
|
||||||
):
|
|
||||||
"""TTS preview / voice test."""
|
|
||||||
mandateId = _validateInstanceAccess(instanceId, context)
|
|
||||||
body = await request.json()
|
|
||||||
text = body.get("text", "Hallo, ich bin dein Coaching-Assistent.")
|
|
||||||
language = body.get("language", "de-DE")
|
|
||||||
voiceId = body.get("voiceId")
|
|
||||||
|
|
||||||
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
|
|
||||||
voiceInterface = getVoiceInterface(context.user, mandateId)
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await voiceInterface.textToSpeech(text=text, languageCode=language, voiceName=voiceId)
|
|
||||||
if result and isinstance(result, dict):
|
|
||||||
audioContent = result.get("audioContent")
|
|
||||||
if audioContent:
|
|
||||||
audioB64 = base64.b64encode(
|
|
||||||
audioContent if isinstance(audioContent, bytes) else audioContent.encode()
|
|
||||||
).decode()
|
|
||||||
return {"success": True, "audio": audioB64, "format": "mp3", "text": text}
|
|
||||||
return {"success": False, "error": "TTS returned no audio"}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Voice test failed: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=f"TTS test failed: {str(e)}")
|
|
||||||
|
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Export Endpoints (Iteration 2)
|
# Export Endpoints (Iteration 2)
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
@ -1074,202 +1006,6 @@ async def deletePersonaRoute(
|
||||||
return {"deleted": True}
|
return {"deleted": True}
|
||||||
|
|
||||||
|
|
||||||
# =========================================================================
|
|
||||||
# Document Endpoints (Iteration 2)
|
|
||||||
# =========================================================================
|
|
||||||
|
|
||||||
@router.get("/{instanceId}/contexts/{contextId}/documents")
|
|
||||||
@limiter.limit("60/minute")
|
|
||||||
async def listDocuments(
|
|
||||||
request: Request,
|
|
||||||
instanceId: str,
|
|
||||||
contextId: str,
|
|
||||||
context: RequestContext = Depends(getRequestContext),
|
|
||||||
):
|
|
||||||
_validateInstanceAccess(instanceId, context)
|
|
||||||
interface = _getInterface(context, instanceId)
|
|
||||||
userId = str(context.user.id)
|
|
||||||
docs = interface.getDocuments(contextId, userId)
|
|
||||||
return {"documents": docs}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{instanceId}/contexts/{contextId}/documents")
|
|
||||||
@limiter.limit("10/minute")
|
|
||||||
async def uploadDocument(
|
|
||||||
request: Request,
|
|
||||||
instanceId: str,
|
|
||||||
contextId: str,
|
|
||||||
context: RequestContext = Depends(getRequestContext),
|
|
||||||
):
|
|
||||||
"""Upload a document and bind it to a context. Stores file in Management DB."""
|
|
||||||
mandateId = _validateInstanceAccess(instanceId, context)
|
|
||||||
interface = _getInterface(context, instanceId)
|
|
||||||
userId = str(context.user.id)
|
|
||||||
|
|
||||||
ctx = interface.getContext(contextId)
|
|
||||||
if not ctx:
|
|
||||||
raise HTTPException(status_code=404, detail="Context not found")
|
|
||||||
_validateOwnership(ctx, context)
|
|
||||||
|
|
||||||
form = await request.form()
|
|
||||||
file = form.get("file")
|
|
||||||
if not file or not hasattr(file, "read"):
|
|
||||||
raise HTTPException(status_code=400, detail="No file uploaded")
|
|
||||||
|
|
||||||
content = await file.read()
|
|
||||||
fileName = getattr(file, "filename", "document")
|
|
||||||
mimeType = getattr(file, "content_type", "application/octet-stream")
|
|
||||||
fileSize = len(content)
|
|
||||||
|
|
||||||
if not content:
|
|
||||||
raise HTTPException(status_code=400, detail="Leere Datei hochgeladen")
|
|
||||||
|
|
||||||
import modules.interfaces.interfaceDbManagement as interfaceDbManagement
|
|
||||||
mgmtInterface = interfaceDbManagement.getInterface(currentUser=context.user)
|
|
||||||
fileItem, _dupType = mgmtInterface.saveUploadedFile(content, fileName)
|
|
||||||
fileRef = fileItem.id
|
|
||||||
|
|
||||||
extractedText = _extractText(content, mimeType, fileName)
|
|
||||||
summary = None
|
|
||||||
if extractedText and len(extractedText.strip()) > 50:
|
|
||||||
try:
|
|
||||||
from .serviceCommcoach import CommcoachService
|
|
||||||
service = CommcoachService(context.user, mandateId, instanceId)
|
|
||||||
aiResp = await service._callAi(
|
|
||||||
"Du fasst Dokumente in 2-3 Saetzen zusammen.",
|
|
||||||
f"Fasse folgendes Dokument zusammen:\n\n{extractedText[:3000]}"
|
|
||||||
)
|
|
||||||
if aiResp and aiResp.errorCount == 0 and aiResp.content:
|
|
||||||
summary = aiResp.content.strip()
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Document summary failed: {e}")
|
|
||||||
|
|
||||||
docData = CoachingDocument(
|
|
||||||
contextId=contextId,
|
|
||||||
userId=userId,
|
|
||||||
mandateId=mandateId,
|
|
||||||
instanceId=instanceId,
|
|
||||||
fileName=fileName,
|
|
||||||
mimeType=mimeType,
|
|
||||||
fileSize=fileSize,
|
|
||||||
extractedText=extractedText[:10000] if extractedText else None,
|
|
||||||
summary=summary,
|
|
||||||
fileRef=fileRef,
|
|
||||||
).model_dump()
|
|
||||||
created = interface.createDocument(docData)
|
|
||||||
return {"document": created}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{instanceId}/documents/{documentId}")
|
|
||||||
@limiter.limit("10/minute")
|
|
||||||
async def deleteDocumentRoute(
|
|
||||||
request: Request,
|
|
||||||
instanceId: str,
|
|
||||||
documentId: str,
|
|
||||||
context: RequestContext = Depends(getRequestContext),
|
|
||||||
):
|
|
||||||
mandateId = _validateInstanceAccess(instanceId, context)
|
|
||||||
interface = _getInterface(context, instanceId)
|
|
||||||
|
|
||||||
doc = interface.getDocument(documentId)
|
|
||||||
if not doc:
|
|
||||||
raise HTTPException(status_code=404, detail="Document not found")
|
|
||||||
_validateOwnership(doc, context)
|
|
||||||
|
|
||||||
fileRef = doc.get("fileRef")
|
|
||||||
if fileRef:
|
|
||||||
try:
|
|
||||||
import modules.interfaces.interfaceDbManagement as interfaceDbManagement
|
|
||||||
mgmtInterface = interfaceDbManagement.getInterface(
|
|
||||||
currentUser=context.user, mandateId=mandateId, featureInstanceId=instanceId
|
|
||||||
)
|
|
||||||
mgmtInterface.deleteFile(fileRef)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to delete file {fileRef}: {e}")
|
|
||||||
|
|
||||||
interface.deleteDocument(documentId)
|
|
||||||
return {"deleted": True}
|
|
||||||
|
|
||||||
|
|
||||||
def _extractText(content: bytes, mimeType: str, fileName: str) -> Optional[str]:
|
|
||||||
"""Extract text from uploaded file content (TXT, MD, HTML, PDF, DOCX, XLSX, PPTX)."""
|
|
||||||
import io
|
|
||||||
|
|
||||||
lowerName = fileName.lower()
|
|
||||||
try:
|
|
||||||
if mimeType in ("text/plain",) or lowerName.endswith(".txt"):
|
|
||||||
return content.decode("utf-8", errors="replace")
|
|
||||||
|
|
||||||
if mimeType in ("text/markdown",) or lowerName.endswith(".md"):
|
|
||||||
return content.decode("utf-8", errors="replace")
|
|
||||||
|
|
||||||
if mimeType in ("text/html",) or lowerName.endswith((".html", ".htm")):
|
|
||||||
from html.parser import HTMLParser
|
|
||||||
class _Strip(HTMLParser):
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self._parts: list[str] = []
|
|
||||||
def handle_data(self, d):
|
|
||||||
self._parts.append(d)
|
|
||||||
def result(self):
|
|
||||||
return " ".join(self._parts)
|
|
||||||
parser = _Strip()
|
|
||||||
parser.feed(content.decode("utf-8", errors="replace"))
|
|
||||||
return parser.result()
|
|
||||||
|
|
||||||
if "pdf" in mimeType or lowerName.endswith(".pdf"):
|
|
||||||
try:
|
|
||||||
from PyPDF2 import PdfReader
|
|
||||||
reader = PdfReader(io.BytesIO(content))
|
|
||||||
return "".join(page.extract_text() or "" for page in reader.pages)
|
|
||||||
except ImportError:
|
|
||||||
logger.warning("PyPDF2 not installed, cannot extract PDF text")
|
|
||||||
return None
|
|
||||||
|
|
||||||
if "wordprocessingml" in mimeType or lowerName.endswith(".docx"):
|
|
||||||
try:
|
|
||||||
from docx import Document
|
|
||||||
doc = Document(io.BytesIO(content))
|
|
||||||
return "\n".join(p.text for p in doc.paragraphs if p.text)
|
|
||||||
except ImportError:
|
|
||||||
logger.warning("python-docx not installed, cannot extract DOCX text")
|
|
||||||
return None
|
|
||||||
|
|
||||||
if "spreadsheetml" in mimeType or lowerName.endswith(".xlsx"):
|
|
||||||
try:
|
|
||||||
from openpyxl import load_workbook
|
|
||||||
wb = load_workbook(io.BytesIO(content), read_only=True, data_only=True)
|
|
||||||
parts: list[str] = []
|
|
||||||
for ws in wb.worksheets:
|
|
||||||
for row in ws.iter_rows(values_only=True):
|
|
||||||
cells = [str(c) for c in row if c is not None]
|
|
||||||
if cells:
|
|
||||||
parts.append("\t".join(cells))
|
|
||||||
return "\n".join(parts)
|
|
||||||
except ImportError:
|
|
||||||
logger.warning("openpyxl not installed, cannot extract XLSX text")
|
|
||||||
return None
|
|
||||||
|
|
||||||
if "presentationml" in mimeType or lowerName.endswith(".pptx"):
|
|
||||||
try:
|
|
||||||
from pptx import Presentation
|
|
||||||
prs = Presentation(io.BytesIO(content))
|
|
||||||
parts = []
|
|
||||||
for slide in prs.slides:
|
|
||||||
for shape in slide.shapes:
|
|
||||||
if shape.has_text_frame:
|
|
||||||
parts.append(shape.text_frame.text)
|
|
||||||
return "\n".join(parts)
|
|
||||||
except ImportError:
|
|
||||||
logger.warning("python-pptx not installed, cannot extract PPTX text")
|
|
||||||
return None
|
|
||||||
|
|
||||||
logger.info(f"No text extractor for {fileName} (mime={mimeType})")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Text extraction failed for {fileName}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Badge + Score History Endpoints (Iteration 2)
|
# Badge + Score History Endpoints (Iteration 2)
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ from .serviceCommcoachContextRetrieval import (
|
||||||
buildSessionSummariesForPrompt,
|
buildSessionSummariesForPrompt,
|
||||||
findSessionByDate,
|
findSessionByDate,
|
||||||
searchSessionsByTopic,
|
searchSessionsByTopic,
|
||||||
|
searchSessionsByTopicRag,
|
||||||
_parseDateFromMessage,
|
_parseDateFromMessage,
|
||||||
PREVIOUS_SESSION_SUMMARIES_COUNT,
|
PREVIOUS_SESSION_SUMMARIES_COUNT,
|
||||||
ROLLING_OVERVIEW_SESSION_THRESHOLD,
|
ROLLING_OVERVIEW_SESSION_THRESHOLD,
|
||||||
|
|
@ -42,6 +43,30 @@ from .serviceCommcoachContextRetrieval import (
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _getUserVoicePrefs(userId: str, mandateId: Optional[str] = None) -> tuple:
|
||||||
|
"""Load voice language and voiceName from central UserVoicePreferences.
|
||||||
|
Returns (language, voiceName) tuple."""
|
||||||
|
try:
|
||||||
|
from modules.datamodels.datamodelUam import UserVoicePreferences
|
||||||
|
from modules.security.rootAccess import getRootInterface
|
||||||
|
rootIf = getRootInterface()
|
||||||
|
prefs = rootIf.db.getRecordset(
|
||||||
|
UserVoicePreferences,
|
||||||
|
recordFilter={"userId": userId, "mandateId": mandateId}
|
||||||
|
)
|
||||||
|
if not prefs and mandateId:
|
||||||
|
prefs = rootIf.db.getRecordset(
|
||||||
|
UserVoicePreferences,
|
||||||
|
recordFilter={"userId": userId}
|
||||||
|
)
|
||||||
|
if prefs:
|
||||||
|
p = prefs[0] if isinstance(prefs[0], dict) else prefs[0].model_dump()
|
||||||
|
return (p.get("ttsLanguage") or p.get("sttLanguage") or "de-DE", p.get("ttsVoice"))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to load UserVoicePreferences for user={userId}: {e}")
|
||||||
|
return ("de-DE", None)
|
||||||
|
|
||||||
|
|
||||||
def _stripMarkdownForTts(text: str) -> str:
|
def _stripMarkdownForTts(text: str) -> str:
|
||||||
"""Strip markdown formatting so TTS reads clean speech text."""
|
"""Strip markdown formatting so TTS reads clean speech text."""
|
||||||
t = text
|
t = text
|
||||||
|
|
@ -159,9 +184,7 @@ async def _generateAndEmitTts(sessionId: str, speechText: str, currentUser, mand
|
||||||
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
|
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
|
||||||
import base64
|
import base64
|
||||||
voiceInterface = getVoiceInterface(currentUser, mandateId)
|
voiceInterface = getVoiceInterface(currentUser, mandateId)
|
||||||
profile = interface.getProfile(str(currentUser.id), instanceId)
|
language, voiceName = _getUserVoicePrefs(str(currentUser.id), mandateId)
|
||||||
language = profile.get("preferredLanguage", "de-DE") if profile else "de-DE"
|
|
||||||
voiceName = profile.get("preferredVoice") if profile else None
|
|
||||||
ttsResult = await voiceInterface.textToSpeech(
|
ttsResult = await voiceInterface.textToSpeech(
|
||||||
text=_stripMarkdownForTts(speechText),
|
text=_stripMarkdownForTts(speechText),
|
||||||
languageCode=language,
|
languageCode=language,
|
||||||
|
|
@ -196,60 +219,36 @@ def _resolveFileNameAndMime(title: str) -> tuple:
|
||||||
async def _saveOrUpdateDocument(doc: Dict[str, Any], contextId: str, userId: str,
|
async def _saveOrUpdateDocument(doc: Dict[str, Any], contextId: str, userId: str,
|
||||||
mandateId: str, instanceId: str, interface, sessionId: str,
|
mandateId: str, instanceId: str, interface, sessionId: str,
|
||||||
user=None):
|
user=None):
|
||||||
"""Save a new document or update an existing one. Stores file in Management DB."""
|
"""Save a document as platform FileItem (no CoachingDocument)."""
|
||||||
from .datamodelCommcoach import CoachingDocument
|
|
||||||
try:
|
try:
|
||||||
docId = doc.get("id")
|
|
||||||
title = doc.get("title", "Dokument")
|
title = doc.get("title", "Dokument")
|
||||||
content = doc.get("content", "")
|
content = doc.get("content", "")
|
||||||
contentBytes = content.encode("utf-8")
|
contentBytes = content.encode("utf-8")
|
||||||
fileName, mimeType = _resolveFileNameAndMime(title)
|
fileName, mimeType = _resolveFileNameAndMime(title)
|
||||||
|
|
||||||
fileRef = None
|
import modules.interfaces.interfaceDbManagement as interfaceDbManagement
|
||||||
try:
|
mgmtInterface = interfaceDbManagement.getInterface(
|
||||||
import modules.interfaces.interfaceDbManagement as interfaceDbManagement
|
currentUser=user, mandateId=mandateId, featureInstanceId=instanceId
|
||||||
mgmtInterface = interfaceDbManagement.getInterface(
|
)
|
||||||
currentUser=user, mandateId=mandateId, featureInstanceId=instanceId
|
fileItem = mgmtInterface.createFile(name=fileName, mimeType=mimeType, content=contentBytes)
|
||||||
)
|
mgmtInterface.createFileData(fileItem.id, contentBytes)
|
||||||
fileItem = mgmtInterface.createFile(name=fileName, mimeType=mimeType, content=contentBytes)
|
|
||||||
mgmtInterface.createFileData(fileItem.id, contentBytes)
|
from modules.datamodels.datamodelFiles import FileItem as FileItemModel
|
||||||
fileRef = fileItem.id
|
mgmtInterface.db.recordModify(FileItemModel, fileItem.id, {
|
||||||
except Exception as e:
|
"scope": "featureInstance",
|
||||||
logger.warning(f"Failed to store document in file DB: {e}")
|
"featureInstanceId": instanceId,
|
||||||
|
"mandateId": mandateId,
|
||||||
|
})
|
||||||
|
|
||||||
|
await emitSessionEvent(sessionId, "documentCreated", {
|
||||||
|
"id": fileItem.id, "fileName": fileName, "fileSize": len(contentBytes),
|
||||||
|
})
|
||||||
|
logger.info(f"Document saved as platform FileItem: {fileItem.id} ({title})")
|
||||||
|
|
||||||
if docId:
|
|
||||||
updates = {
|
|
||||||
"fileName": fileName,
|
|
||||||
"mimeType": mimeType,
|
|
||||||
"extractedText": content,
|
|
||||||
"summary": title,
|
|
||||||
"fileSize": len(contentBytes),
|
|
||||||
}
|
|
||||||
if fileRef:
|
|
||||||
updates["fileRef"] = fileRef
|
|
||||||
updated = interface.updateDocument(docId, updates)
|
|
||||||
if updated:
|
|
||||||
await emitSessionEvent(sessionId, "documentUpdated", updated)
|
|
||||||
logger.info(f"Document updated: {docId} ({title})")
|
|
||||||
else:
|
|
||||||
logger.warning(f"Document update failed, id not found: {docId}")
|
|
||||||
else:
|
|
||||||
docData = CoachingDocument(
|
|
||||||
contextId=contextId,
|
|
||||||
userId=userId,
|
|
||||||
mandateId=mandateId,
|
|
||||||
instanceId=instanceId,
|
|
||||||
fileName=fileName,
|
|
||||||
mimeType=mimeType,
|
|
||||||
fileSize=len(contentBytes),
|
|
||||||
extractedText=content,
|
|
||||||
summary=title,
|
|
||||||
fileRef=fileRef,
|
|
||||||
).model_dump()
|
|
||||||
created = interface.createDocument(docData)
|
|
||||||
await emitSessionEvent(sessionId, "documentCreated", created)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to save/update document: {e}")
|
logger.warning(f"Failed to save document as FileItem: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def _resolveDocumentIntent(combinedUserPrompt: str, docs: List[Dict[str, Any]], callAiFn) -> Dict[str, Any]:
|
async def _resolveDocumentIntent(combinedUserPrompt: str, docs: List[Dict[str, Any]], callAiFn) -> Dict[str, Any]:
|
||||||
|
|
@ -269,17 +268,60 @@ async def _resolveDocumentIntent(combinedUserPrompt: str, docs: List[Dict[str, A
|
||||||
return {"read": [], "update": [], "create": [], "noDocumentAction": True}
|
return {"read": [], "update": [], "create": [], "noDocumentAction": True}
|
||||||
|
|
||||||
|
|
||||||
def _loadDocumentContents(docIds: List[str], interface) -> List[Dict[str, Any]]:
|
def _getPlatformFileList(mandateId: str = None, instanceId: str = None) -> List[Dict[str, Any]]:
|
||||||
"""Load full extractedText for the given document IDs."""
|
"""Get list of platform FileItems for this feature instance (for doc intent detection)."""
|
||||||
results = []
|
try:
|
||||||
for docId in docIds[:DOC_INTENT_MAX_DOCS]:
|
import modules.interfaces.interfaceDbManagement as interfaceDbManagement
|
||||||
doc = interface.getDocument(docId)
|
from modules.datamodels.datamodelFiles import FileItem
|
||||||
if doc and doc.get("extractedText"):
|
mgmtIf = interfaceDbManagement.getInterface(
|
||||||
results.append({
|
currentUser=None, mandateId=mandateId, featureInstanceId=instanceId
|
||||||
"id": doc.get("id", ""),
|
)
|
||||||
"title": doc.get("summary") or doc.get("fileName", ""),
|
records = mgmtIf.db.getRecordset(
|
||||||
"content": doc.get("extractedText", "")[:DOC_CONTENT_MAX_CHARS],
|
FileItem, recordFilter={"featureInstanceId": instanceId}
|
||||||
|
) if instanceId else []
|
||||||
|
result = []
|
||||||
|
for r in records:
|
||||||
|
d = r if isinstance(r, dict) else r.model_dump() if hasattr(r, "model_dump") else {}
|
||||||
|
result.append({
|
||||||
|
"id": d.get("id", ""),
|
||||||
|
"fileName": d.get("fileName") or d.get("name") or "Dokument",
|
||||||
|
"summary": d.get("fileName") or "",
|
||||||
})
|
})
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to load platform file list: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _loadDocumentContents(docIds: List[str], interface, mandateId: str = None, instanceId: str = None) -> List[Dict[str, Any]]:
|
||||||
|
"""Load file content for given IDs from platform FileItem store."""
|
||||||
|
results = []
|
||||||
|
try:
|
||||||
|
import modules.interfaces.interfaceDbManagement as interfaceDbManagement
|
||||||
|
from modules.datamodels.datamodelFiles import FileItem
|
||||||
|
mgmtIf = interfaceDbManagement.getInterface(
|
||||||
|
currentUser=None, mandateId=mandateId, featureInstanceId=instanceId
|
||||||
|
)
|
||||||
|
for fId in docIds[:DOC_INTENT_MAX_DOCS]:
|
||||||
|
fileRecords = mgmtIf.db.getRecordset(FileItem, recordFilter={"id": fId})
|
||||||
|
if fileRecords:
|
||||||
|
f = fileRecords[0] if isinstance(fileRecords[0], dict) else fileRecords[0].model_dump()
|
||||||
|
content = ""
|
||||||
|
try:
|
||||||
|
from modules.datamodels.datamodelKnowledge import FileContentIndex
|
||||||
|
idxRecords = mgmtIf.db.getRecordset(FileContentIndex, recordFilter={"fileId": fId})
|
||||||
|
if idxRecords:
|
||||||
|
idx = idxRecords[0] if isinstance(idxRecords[0], dict) else idxRecords[0].model_dump()
|
||||||
|
content = (idx.get("extractedText") or "")[:DOC_CONTENT_MAX_CHARS]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
results.append({
|
||||||
|
"id": fId,
|
||||||
|
"title": f.get("fileName") or f.get("name") or "Dokument",
|
||||||
|
"content": content,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to load document contents from platform: {e}")
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -319,20 +361,42 @@ def _resolvePersona(session: Optional[Dict[str, Any]], interface) -> Optional[Di
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _getDocumentSummaries(contextId: str, userId: str, interface) -> Optional[List[str]]:
|
def _getDocumentSummaries(contextId: str, userId: str, interface,
|
||||||
"""Get document summaries for context to include in the AI prompt."""
|
mandateId: str = None, instanceId: str = None) -> Optional[List[str]]:
|
||||||
|
"""Get document summaries from platform FileItems (UDL) for the coaching instance."""
|
||||||
try:
|
try:
|
||||||
docs = interface.getDocuments(contextId, userId)
|
import modules.interfaces.interfaceDbManagement as interfaceDbManagement
|
||||||
|
from modules.datamodels.datamodelFiles import FileItem
|
||||||
|
mgmtIf = interfaceDbManagement.getInterface(
|
||||||
|
currentUser=None, mandateId=mandateId, featureInstanceId=instanceId
|
||||||
|
)
|
||||||
|
files = mgmtIf.db.getRecordset(
|
||||||
|
FileItem, recordFilter={"featureInstanceId": instanceId}
|
||||||
|
) if instanceId else []
|
||||||
summaries = []
|
summaries = []
|
||||||
for doc in docs[:5]:
|
for f in files[:10]:
|
||||||
summary = doc.get("summary")
|
fData = f if isinstance(f, dict) else f.model_dump() if hasattr(f, "model_dump") else {}
|
||||||
if summary:
|
name = fData.get("fileName") or fData.get("name") or "Dokument"
|
||||||
summaries.append(f"[{doc.get('fileName', 'Dokument')}] {summary}")
|
fId = fData.get("id")
|
||||||
elif doc.get("extractedText"):
|
snippet = None
|
||||||
summaries.append(f"[{doc.get('fileName', 'Dokument')}] {doc['extractedText'][:200]}...")
|
if fId:
|
||||||
|
try:
|
||||||
|
from modules.datamodels.datamodelKnowledge import FileContentIndex
|
||||||
|
idxRecords = mgmtIf.db.getRecordset(
|
||||||
|
FileContentIndex, recordFilter={"fileId": fId}
|
||||||
|
)
|
||||||
|
if idxRecords:
|
||||||
|
idx = idxRecords[0] if isinstance(idxRecords[0], dict) else idxRecords[0].model_dump()
|
||||||
|
snippet = (idx.get("extractedText") or "")[:200]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if snippet:
|
||||||
|
summaries.append(f"[{name}] {snippet}...")
|
||||||
|
else:
|
||||||
|
summaries.append(f"[{name}]")
|
||||||
return summaries if summaries else None
|
return summaries if summaries else None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to load document summaries for context {contextId}: {e}")
|
logger.warning(f"Failed to load platform file summaries for instance {instanceId}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -427,18 +491,22 @@ class CommcoachService:
|
||||||
)
|
)
|
||||||
|
|
||||||
persona = _resolvePersona(session, interface)
|
persona = _resolvePersona(session, interface)
|
||||||
documentSummaries = _getDocumentSummaries(contextId, self.userId, interface)
|
documentSummaries = _getDocumentSummaries(
|
||||||
|
contextId, self.userId, interface, mandateId=self.mandateId, instanceId=self.instanceId
|
||||||
|
)
|
||||||
|
|
||||||
# Document intent detection (pre-AI-call)
|
# Document intent detection (pre-AI-call)
|
||||||
referencedDocumentContents = None
|
referencedDocumentContents = None
|
||||||
allDocs = interface.getDocuments(contextId, self.userId) if documentSummaries else []
|
allDocs = _getPlatformFileList(self.mandateId, self.instanceId) if documentSummaries else []
|
||||||
if allDocs:
|
if allDocs:
|
||||||
await emitSessionEvent(sessionId, "status", {"label": "Dokumente werden geprueft..."})
|
await emitSessionEvent(sessionId, "status", {"label": "Dokumente werden geprueft..."})
|
||||||
docIntent = await _resolveDocumentIntent(combinedUserPrompt, allDocs, self._callAi)
|
docIntent = await _resolveDocumentIntent(combinedUserPrompt, allDocs, self._callAi)
|
||||||
if not docIntent.get("noDocumentAction"):
|
if not docIntent.get("noDocumentAction"):
|
||||||
docIdsToLoad = list(set((docIntent.get("read") or []) + (docIntent.get("update") or [])))
|
docIdsToLoad = list(set((docIntent.get("read") or []) + (docIntent.get("update") or [])))
|
||||||
if docIdsToLoad:
|
if docIdsToLoad:
|
||||||
referencedDocumentContents = _loadDocumentContents(docIdsToLoad, interface)
|
referencedDocumentContents = _loadDocumentContents(
|
||||||
|
docIdsToLoad, interface, mandateId=self.mandateId, instanceId=self.instanceId
|
||||||
|
)
|
||||||
|
|
||||||
systemPrompt = aiPrompts.buildCoachingSystemPrompt(
|
systemPrompt = aiPrompts.buildCoachingSystemPrompt(
|
||||||
context,
|
context,
|
||||||
|
|
@ -536,7 +604,9 @@ class CommcoachService:
|
||||||
|
|
||||||
session = interface.getSession(sessionId)
|
session = interface.getSession(sessionId)
|
||||||
persona = _resolvePersona(session, interface)
|
persona = _resolvePersona(session, interface)
|
||||||
documentSummaries = _getDocumentSummaries(contextId, self.userId, interface)
|
documentSummaries = _getDocumentSummaries(
|
||||||
|
contextId, self.userId, interface, mandateId=self.mandateId, instanceId=self.instanceId
|
||||||
|
)
|
||||||
|
|
||||||
systemPrompt = aiPrompts.buildCoachingSystemPrompt(
|
systemPrompt = aiPrompts.buildCoachingSystemPrompt(
|
||||||
context, previousMessages, tasks,
|
context, previousMessages, tasks,
|
||||||
|
|
@ -966,10 +1036,29 @@ class CommcoachService:
|
||||||
result["rollingOverview"] = rollingOverview
|
result["rollingOverview"] = rollingOverview
|
||||||
|
|
||||||
elif intent == RetrievalIntent.RECALL_TOPIC:
|
elif intent == RetrievalIntent.RECALL_TOPIC:
|
||||||
retrieved = searchSessionsByTopic(completedSessions, userContent)
|
retrieved = list(searchSessionsByTopic(completedSessions, userContent))
|
||||||
|
queryVector = await self._embedUserQuery(userContent)
|
||||||
|
if queryVector:
|
||||||
|
ragHits = searchSessionsByTopicRag(
|
||||||
|
userContent,
|
||||||
|
self.userId,
|
||||||
|
self.instanceId,
|
||||||
|
mandateId=self.mandateId,
|
||||||
|
queryVector=queryVector,
|
||||||
|
)
|
||||||
|
for hit in ragHits:
|
||||||
|
content = (hit.get("content") or "").strip()
|
||||||
|
if not content:
|
||||||
|
continue
|
||||||
|
retrieved.append({
|
||||||
|
"summary": content[:450],
|
||||||
|
"date": "",
|
||||||
|
"source": "rag",
|
||||||
|
"ragSourceLabel": hit.get("fileName") or "Mandantenwissen",
|
||||||
|
})
|
||||||
result["retrievedByTopic"] = retrieved
|
result["retrievedByTopic"] = retrieved
|
||||||
if retrieved:
|
if retrieved:
|
||||||
logger.info(f"Topic recall: found {len(retrieved)} sessions for query")
|
logger.info(f"Topic recall: {len(retrieved)} item(s) (sessions + optional RAG)")
|
||||||
result["previousSessionSummaries"] = buildSessionSummariesForPrompt(
|
result["previousSessionSummaries"] = buildSessionSummariesForPrompt(
|
||||||
allSessions, excludeSessionId=sessionId, limit=PREVIOUS_SESSION_SUMMARIES_COUNT
|
allSessions, excludeSessionId=sessionId, limit=PREVIOUS_SESSION_SUMMARIES_COUNT
|
||||||
)
|
)
|
||||||
|
|
@ -1032,3 +1121,31 @@ class CommcoachService:
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return await aiService.callAi(aiRequest)
|
return await aiService.callAi(aiRequest)
|
||||||
|
|
||||||
|
async def _embedUserQuery(self, text: str) -> Optional[List[float]]:
|
||||||
|
"""Embedding for mandate-wide RAG (same ServiceCenter AI service as coaching calls)."""
|
||||||
|
snippet = (text or "").strip()[:2000]
|
||||||
|
if not snippet:
|
||||||
|
return None
|
||||||
|
from modules.serviceCenter import getService
|
||||||
|
from modules.serviceCenter.context import ServiceCenterContext
|
||||||
|
|
||||||
|
serviceContext = ServiceCenterContext(
|
||||||
|
user=self.currentUser,
|
||||||
|
mandate_id=self.mandateId,
|
||||||
|
feature_instance_id=self.instanceId,
|
||||||
|
)
|
||||||
|
aiService = getService("ai", serviceContext)
|
||||||
|
await aiService.ensureAiObjectsInitialized()
|
||||||
|
try:
|
||||||
|
response = await aiService.callEmbedding([snippet])
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"CommCoach RAG embedding failed: {e}")
|
||||||
|
return None
|
||||||
|
if not response or response.errorCount > 0:
|
||||||
|
return None
|
||||||
|
embs = (response.metadata or {}).get("embeddings") or []
|
||||||
|
vec = embs[0] if embs else None
|
||||||
|
if isinstance(vec, list) and len(vec) > 0:
|
||||||
|
return vec
|
||||||
|
return None
|
||||||
|
|
|
||||||
|
|
@ -229,12 +229,18 @@ WICHTIG: Antworte NUR mit dem JSON-Objekt. Kein Text vor oder nach dem JSON."""
|
||||||
prompt += f"\n{retrievedSession.get('summary', '')[:500]}"
|
prompt += f"\n{retrievedSession.get('summary', '')[:500]}"
|
||||||
|
|
||||||
if retrievedByTopic:
|
if retrievedByTopic:
|
||||||
prompt += "\n\nRelevante Sessions zum angefragten Thema:"
|
prompt += "\n\nRelevante Sessions und Mandantenwissen zum angefragten Thema:"
|
||||||
for s in retrievedByTopic[:3]:
|
for s in retrievedByTopic[:5]:
|
||||||
summary = s.get("summary", "")
|
summary = s.get("summary", s.get("content", ""))
|
||||||
|
if not summary:
|
||||||
|
continue
|
||||||
dateStr = s.get("date", "")
|
dateStr = s.get("date", "")
|
||||||
if summary:
|
if s.get("source") == "rag":
|
||||||
prompt += f"\n- [{dateStr}] {summary[:300]}"
|
label = s.get("ragSourceLabel") or "Mandantenwissen"
|
||||||
|
prompt += f"\n- [Wissen: {label}] {summary[:320]}"
|
||||||
|
else:
|
||||||
|
prefix = f"[{dateStr}] " if dateStr else ""
|
||||||
|
prompt += f"\n- {prefix}{summary[:300]}"
|
||||||
|
|
||||||
if openTasks:
|
if openTasks:
|
||||||
prompt += "\n\nOffene Aufgaben:"
|
prompt += "\n\nOffene Aufgaben:"
|
||||||
|
|
|
||||||
|
|
@ -172,20 +172,48 @@ def searchSessionsByTopic(
|
||||||
|
|
||||||
|
|
||||||
def searchSessionsByTopicRag(
|
def searchSessionsByTopicRag(
|
||||||
sessions: List[Dict[str, Any]],
|
|
||||||
query: str,
|
query: str,
|
||||||
maxResults: int = TOPIC_SEARCH_MAX_RESULTS,
|
userId: str,
|
||||||
embeddingProvider: Optional[Any] = None,
|
instanceId: str,
|
||||||
|
mandateId: str = None,
|
||||||
|
queryVector: List[float] = None,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Search using platform RAG (semantic search across mandate-wide knowledge data).
|
||||||
|
|
||||||
|
Requires a pre-computed queryVector (embedding). The caller is responsible
|
||||||
|
for generating the embedding via AiService.callEmbedding before invoking this.
|
||||||
"""
|
"""
|
||||||
Phase 7 RAG: Semantic search via embeddings.
|
if not queryVector:
|
||||||
When embeddingProvider is None, falls back to keyword search.
|
logger.warning("searchSessionsByTopicRag called without queryVector, skipping RAG search")
|
||||||
Future: Pass embeddingProvider that has embed(text) -> vector and similarity search.
|
return []
|
||||||
"""
|
try:
|
||||||
if embeddingProvider is None:
|
from modules.interfaces.interfaceDbKnowledge import getInterface as _getKnowledgeInterface
|
||||||
return searchSessionsByTopic(sessions, query, maxResults)
|
|
||||||
# TODO: When embedding API exists: embed query, embed session summaries, cosine similarity
|
knowledgeDb = _getKnowledgeInterface()
|
||||||
return searchSessionsByTopic(sessions, query, maxResults)
|
|
||||||
|
results = knowledgeDb.semanticSearch(
|
||||||
|
queryVector=queryVector,
|
||||||
|
userId=userId,
|
||||||
|
featureInstanceId=instanceId,
|
||||||
|
mandateId=mandateId,
|
||||||
|
isSysAdmin=False,
|
||||||
|
limit=TOPIC_SEARCH_MAX_RESULTS,
|
||||||
|
)
|
||||||
|
|
||||||
|
formatted = []
|
||||||
|
for r in (results or []):
|
||||||
|
rData = r if isinstance(r, dict) else r.model_dump() if hasattr(r, "model_dump") else {}
|
||||||
|
contextRef = rData.get("contextRef") or {}
|
||||||
|
formatted.append({
|
||||||
|
"source": "rag",
|
||||||
|
"content": rData.get("data") or rData.get("summary") or "",
|
||||||
|
"fileName": contextRef.get("containerPath") or "RAG-Ergebnis",
|
||||||
|
"score": rData.get("_score") or 0,
|
||||||
|
})
|
||||||
|
return formatted
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"RAG search failed for query '{query[:50]}': {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
def buildSessionSummariesForPrompt(
|
def buildSessionSummariesForPrompt(
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,6 @@ class TestCoachingUserProfile:
|
||||||
profile = CoachingUserProfile(
|
profile = CoachingUserProfile(
|
||||||
userId="u1", mandateId="m1", instanceId="i1",
|
userId="u1", mandateId="m1", instanceId="i1",
|
||||||
)
|
)
|
||||||
assert profile.preferredLanguage == "de-DE"
|
|
||||||
assert profile.dailyReminderEnabled is False
|
assert profile.dailyReminderEnabled is False
|
||||||
assert profile.emailSummaryEnabled is True
|
assert profile.emailSummaryEnabled is True
|
||||||
assert profile.streakDays == 0
|
assert profile.streakDays == 0
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ class TestFeatureDefinition:
|
||||||
assert defn["code"] == "commcoach"
|
assert defn["code"] == "commcoach"
|
||||||
assert "label" in defn
|
assert "label" in defn
|
||||||
assert "icon" in defn
|
assert "icon" in defn
|
||||||
assert defn["autoCreateInstance"] is True
|
assert defn["autoCreateInstance"] is False
|
||||||
|
|
||||||
|
|
||||||
class TestRbacObjects:
|
class TestRbacObjects:
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,33 @@
|
||||||
"""Neutralizer models: DataNeutraliserConfig and DataNeutralizerAttributes."""
|
"""Neutralizer models: DataNeutraliserConfig and DataNeutralizerAttributes."""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from enum import Enum
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from modules.datamodels.datamodelBase import PowerOnModel
|
||||||
from modules.shared.attributeUtils import registerModelLabels
|
from modules.shared.attributeUtils import registerModelLabels
|
||||||
|
|
||||||
|
|
||||||
class DataNeutraliserConfig(BaseModel):
|
class DataScope(str, Enum):
|
||||||
|
PERSONAL = "personal"
|
||||||
|
FEATURE_INSTANCE = "featureInstance"
|
||||||
|
MANDATE = "mandate"
|
||||||
|
GLOBAL = "global"
|
||||||
|
|
||||||
|
|
||||||
|
class DataNeutraliserConfig(PowerOnModel):
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the configuration", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the configuration", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||||
mandateId: str = Field(description="ID of the mandate this configuration belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
mandateId: str = Field(description="ID of the mandate this configuration belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
||||||
featureInstanceId: str = Field(description="ID of the feature instance this configuration belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
featureInstanceId: str = Field(description="ID of the feature instance this configuration belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
||||||
userId: str = Field(description="ID of the user who created this configuration", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
userId: str = Field(description="ID of the user who created this configuration", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
||||||
enabled: bool = Field(default=True, description="Whether data neutralization is enabled", json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False})
|
enabled: bool = Field(default=True, description="Whether data neutralization is enabled", json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False})
|
||||||
|
scope: str = Field(default="personal", description="Data visibility scope: personal, featureInstance, mandate, global", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
|
||||||
|
{"value": "personal", "label": {"en": "Personal", "de": "Persönlich"}},
|
||||||
|
{"value": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}},
|
||||||
|
{"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}},
|
||||||
|
{"value": "global", "label": {"en": "Global", "de": "Global"}},
|
||||||
|
]})
|
||||||
|
neutralizationStatus: str = Field(default="not_required", description="Status of neutralization: pending, completed, failed, not_required", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||||
namesToParse: str = Field(default="", description="Multiline list of names to parse for neutralization", json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False})
|
namesToParse: str = Field(default="", description="Multiline list of names to parse for neutralization", json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False})
|
||||||
sharepointSourcePath: str = Field(default="", description="SharePoint path to read files for neutralization", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
|
sharepointSourcePath: str = Field(default="", description="SharePoint path to read files for neutralization", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
|
||||||
sharepointTargetPath: str = Field(default="", description="SharePoint path to store neutralized files", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
|
sharepointTargetPath: str = Field(default="", description="SharePoint path to store neutralized files", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
|
||||||
|
|
@ -26,6 +42,8 @@ registerModelLabels(
|
||||||
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
|
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
|
||||||
"userId": {"en": "User ID", "fr": "ID utilisateur"},
|
"userId": {"en": "User ID", "fr": "ID utilisateur"},
|
||||||
"enabled": {"en": "Enabled", "fr": "Activé"},
|
"enabled": {"en": "Enabled", "fr": "Activé"},
|
||||||
|
"scope": {"en": "Scope", "fr": "Portée"},
|
||||||
|
"neutralizationStatus": {"en": "Neutralization Status", "fr": "Statut de neutralisation"},
|
||||||
"namesToParse": {"en": "Names to Parse", "fr": "Noms à analyser"},
|
"namesToParse": {"en": "Names to Parse", "fr": "Noms à analyser"},
|
||||||
"sharepointSourcePath": {"en": "Source Path", "fr": "Chemin source"},
|
"sharepointSourcePath": {"en": "Source Path", "fr": "Chemin source"},
|
||||||
"sharepointTargetPath": {"en": "Target Path", "fr": "Chemin cible"},
|
"sharepointTargetPath": {"en": "Target Path", "fr": "Chemin cible"},
|
||||||
|
|
@ -40,6 +58,17 @@ class DataNeutralizerAttributes(BaseModel):
|
||||||
originalText: str = Field(description="Original text that was neutralized", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
originalText: str = Field(description="Original text that was neutralized", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
||||||
fileId: Optional[str] = Field(default=None, description="ID of the file this attribute belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
fileId: Optional[str] = Field(default=None, description="ID of the file this attribute belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||||
patternType: str = Field(description="Type of pattern that matched (email, phone, name, etc.)", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
patternType: str = Field(description="Type of pattern that matched (email, phone, name, etc.)", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
||||||
|
|
||||||
|
|
||||||
|
class DataNeutralizationSnapshot(BaseModel):
|
||||||
|
"""Stores the full neutralized text (with embedded placeholders) per source."""
|
||||||
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
mandateId: str = Field(description="Mandate scope")
|
||||||
|
featureInstanceId: str = Field(default="", description="Feature instance scope")
|
||||||
|
userId: str = Field(description="User who triggered neutralization")
|
||||||
|
sourceLabel: str = Field(description="Human label, e.g. 'Prompt', 'Kontext', 'Nachricht 3'")
|
||||||
|
neutralizedText: str = Field(description="Full text with [type.uuid] placeholders embedded")
|
||||||
|
placeholderCount: int = Field(default=0, description="Number of placeholders in the text")
|
||||||
registerModelLabels(
|
registerModelLabels(
|
||||||
"DataNeutralizerAttributes",
|
"DataNeutralizerAttributes",
|
||||||
{"en": "Neutralized Data Attribute", "fr": "Attribut de données neutralisées"},
|
{"en": "Neutralized Data Attribute", "fr": "Attribut de données neutralisées"},
|
||||||
|
|
@ -53,5 +82,18 @@ registerModelLabels(
|
||||||
"patternType": {"en": "Pattern Type", "fr": "Type de modèle"},
|
"patternType": {"en": "Pattern Type", "fr": "Type de modèle"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
registerModelLabels(
|
||||||
|
"DataNeutralizationSnapshot",
|
||||||
|
{"en": "Neutralization Snapshot", "de": "Neutralisierungs-Snapshot"},
|
||||||
|
{
|
||||||
|
"id": {"en": "ID"},
|
||||||
|
"mandateId": {"en": "Mandate ID"},
|
||||||
|
"featureInstanceId": {"en": "Feature Instance ID"},
|
||||||
|
"userId": {"en": "User ID"},
|
||||||
|
"sourceLabel": {"en": "Source", "de": "Quelle"},
|
||||||
|
"neutralizedText": {"en": "Neutralized Text", "de": "Neutralisierter Text"},
|
||||||
|
"placeholderCount": {"en": "Placeholders", "de": "Platzhalter"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from typing import Dict, List, Any, Optional
|
||||||
from modules.features.neutralization.datamodelFeatureNeutralizer import (
|
from modules.features.neutralization.datamodelFeatureNeutralizer import (
|
||||||
DataNeutraliserConfig,
|
DataNeutraliserConfig,
|
||||||
DataNeutralizerAttributes,
|
DataNeutralizerAttributes,
|
||||||
|
DataNeutralizationSnapshot,
|
||||||
)
|
)
|
||||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||||
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
|
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
|
||||||
|
|
@ -212,6 +213,89 @@ class InterfaceFeatureNeutralizer:
|
||||||
logger.error(f"Error getting attribute by ID: {str(e)}")
|
logger.error(f"Error getting attribute by ID: {str(e)}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def deleteAttributeById(self, attributeId: str) -> bool:
|
||||||
|
"""Delete a single neutralization attribute by its ID"""
|
||||||
|
try:
|
||||||
|
attribute = self.getAttributeById(attributeId)
|
||||||
|
if not attribute:
|
||||||
|
logger.warning(f"Attribute {attributeId} not found for deletion")
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.db.recordDelete(DataNeutralizerAttributes, attributeId)
|
||||||
|
logger.info(f"Deleted neutralization attribute {attributeId}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting attribute by ID: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Snapshot CRUD
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def getSnapshots(self) -> List[DataNeutralizationSnapshot]:
|
||||||
|
"""Return all neutralization snapshots for the current mandate + feature instance."""
|
||||||
|
try:
|
||||||
|
_filter: Dict[str, Any] = {"mandateId": self.mandateId}
|
||||||
|
if self.featureInstanceId:
|
||||||
|
_filter["featureInstanceId"] = self.featureInstanceId
|
||||||
|
rows = getRecordsetWithRBAC(
|
||||||
|
self.db,
|
||||||
|
DataNeutralizationSnapshot,
|
||||||
|
self.currentUser,
|
||||||
|
recordFilter=_filter,
|
||||||
|
mandateId=self.mandateId,
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
DataNeutralizationSnapshot(**{k: v for k, v in r.items() if not k.startswith("_")})
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting snapshots: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def clearSnapshots(self) -> int:
|
||||||
|
"""Delete all snapshots for the current feature-instance scope. Returns count deleted."""
|
||||||
|
try:
|
||||||
|
_filter: Dict[str, Any] = {"mandateId": self.mandateId}
|
||||||
|
if self.featureInstanceId:
|
||||||
|
_filter["featureInstanceId"] = self.featureInstanceId
|
||||||
|
existing = self.db.getRecordset(DataNeutralizationSnapshot, recordFilter=_filter)
|
||||||
|
for row in existing:
|
||||||
|
self.db.recordDelete(DataNeutralizationSnapshot, row["id"])
|
||||||
|
return len(existing)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error clearing snapshots: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def createSnapshot(
|
||||||
|
self,
|
||||||
|
sourceLabel: str,
|
||||||
|
neutralizedText: str,
|
||||||
|
placeholderCount: int = 0,
|
||||||
|
) -> Optional[DataNeutralizationSnapshot]:
|
||||||
|
"""Persist one neutralization snapshot."""
|
||||||
|
try:
|
||||||
|
if not self.userId:
|
||||||
|
logger.warning("Cannot create snapshot: missing userId")
|
||||||
|
return None
|
||||||
|
snap = DataNeutralizationSnapshot(
|
||||||
|
mandateId=self.mandateId or "",
|
||||||
|
featureInstanceId=self.featureInstanceId or "",
|
||||||
|
userId=self.userId,
|
||||||
|
sourceLabel=sourceLabel,
|
||||||
|
neutralizedText=neutralizedText,
|
||||||
|
placeholderCount=placeholderCount,
|
||||||
|
)
|
||||||
|
created = self.db.recordCreate(DataNeutralizationSnapshot, snap.model_dump())
|
||||||
|
return DataNeutralizationSnapshot(**{k: v for k, v in created.items() if not k.startswith("_")})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating snapshot: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Attribute CRUD
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def createAttribute(
|
def createAttribute(
|
||||||
self,
|
self,
|
||||||
attributeId: str,
|
attributeId: str,
|
||||||
|
|
|
||||||
|
|
@ -45,34 +45,55 @@ RESOURCE_OBJECTS = [
|
||||||
|
|
||||||
# Template roles for this feature
|
# Template roles for this feature
|
||||||
TEMPLATE_ROLES = [
|
TEMPLATE_ROLES = [
|
||||||
|
{
|
||||||
|
"roleLabel": "neutralization-viewer",
|
||||||
|
"description": {
|
||||||
|
"en": "Neutralization Viewer - View neutralization data (read-only)",
|
||||||
|
"de": "Neutralisierungs-Betrachter - Neutralisierungsdaten einsehen (nur lesen)",
|
||||||
|
"fr": "Visualiseur neutralisation - Consulter les données de neutralisation (lecture seule)",
|
||||||
|
},
|
||||||
|
"accessRules": [
|
||||||
|
{"context": "UI", "item": "ui.feature.neutralization.playground", "view": True},
|
||||||
|
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roleLabel": "neutralization-user",
|
||||||
|
"description": {
|
||||||
|
"en": "Neutralization User - Use neutralization tools and manage own data",
|
||||||
|
"de": "Neutralisierungs-Benutzer - Neutralisierungstools nutzen und eigene Daten verwalten",
|
||||||
|
"fr": "Utilisateur neutralisation - Utiliser les outils et gérer ses propres données",
|
||||||
|
},
|
||||||
|
"accessRules": [
|
||||||
|
{"context": "UI", "item": "ui.feature.neutralization.playground", "view": True},
|
||||||
|
{"context": "UI", "item": "ui.feature.neutralization.attributes", "view": True},
|
||||||
|
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"roleLabel": "neutralization-admin",
|
"roleLabel": "neutralization-admin",
|
||||||
"description": {
|
"description": {
|
||||||
"en": "Neutralization Administrator - Full access to neutralization settings and data",
|
"en": "Neutralization Administrator - Full access to neutralization settings and data",
|
||||||
"de": "Neutralisierungs-Administrator - Vollzugriff auf Neutralisierungs-Einstellungen und Daten",
|
"de": "Neutralisierungs-Administrator - Vollzugriff auf Neutralisierungs-Einstellungen und Daten",
|
||||||
"fr": "Administrateur neutralisation - Accès complet aux paramètres et données"
|
"fr": "Administrateur neutralisation - Accès complet aux paramètres et données",
|
||||||
},
|
},
|
||||||
"accessRules": [
|
"accessRules": [
|
||||||
# Full UI access (all views including admin views)
|
|
||||||
{"context": "UI", "item": None, "view": True},
|
{"context": "UI", "item": None, "view": True},
|
||||||
# Full DATA access
|
|
||||||
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"},
|
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"},
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"roleLabel": "neutralization-analyst",
|
"roleLabel": "neutralization-analyst",
|
||||||
"description": {
|
"description": {
|
||||||
"en": "Neutralization Analyst - Analyze and process neutralization data",
|
"en": "Neutralization Analyst - Analyze and process neutralization data",
|
||||||
"de": "Neutralisierungs-Analyst - Neutralisierungsdaten analysieren und verarbeiten",
|
"de": "Neutralisierungs-Analyst - Neutralisierungsdaten analysieren und verarbeiten",
|
||||||
"fr": "Analyste neutralisation - Analyser et traiter les données de neutralisation"
|
"fr": "Analyste neutralisation - Analyser et traiter les données de neutralisation",
|
||||||
},
|
},
|
||||||
"accessRules": [
|
"accessRules": [
|
||||||
# UI access to specific views - vollqualifizierte ObjectKeys
|
|
||||||
{"context": "UI", "item": "ui.feature.neutralization.playground", "view": True},
|
{"context": "UI", "item": "ui.feature.neutralization.playground", "view": True},
|
||||||
{"context": "UI", "item": "ui.feature.neutralization.attributes", "view": True},
|
{"context": "UI", "item": "ui.feature.neutralization.attributes", "view": True},
|
||||||
# Group-level DATA access (read-only for sensitive config)
|
|
||||||
{"context": "DATA", "item": None, "view": True, "read": "g", "create": "n", "update": "n", "delete": "n"},
|
{"context": "DATA", "item": None, "view": True, "read": "g", "create": "n", "update": "n", "delete": "n"},
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@ from typing import Any, Dict, List, Optional
|
||||||
from urllib.parse import urlparse, unquote
|
from urllib.parse import urlparse, unquote
|
||||||
|
|
||||||
from modules.datamodels.datamodelUam import User
|
from modules.datamodels.datamodelUam import User
|
||||||
from .datamodelFeatureNeutralizer import DataNeutralizerAttributes, DataNeutraliserConfig
|
from .datamodelFeatureNeutralizer import DataNeutralizerAttributes, DataNeutraliserConfig, DataNeutralizationSnapshot
|
||||||
|
from .interfaceFeatureNeutralizer import getInterface as _getNeutralizerInterface
|
||||||
from modules.serviceHub import getInterface as getServices
|
from modules.serviceHub import getInterface as getServices
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -85,7 +86,7 @@ class NeutralizationPlayground:
|
||||||
'neutralized_file_id': None,
|
'neutralized_file_id': None,
|
||||||
'processed_info': {'type': 'error', 'error': 'File could not be decoded as text. Supported: UTF-8, Latin-1. For PDF/Word/Excel, use supported binary formats.'}
|
'processed_info': {'type': 'error', 'error': 'File could not be decoded as text. Supported: UTF-8, Latin-1. For PDF/Word/Excel, use supported binary formats.'}
|
||||||
}
|
}
|
||||||
result = self.services.neutralization.processText(text_content)
|
result = await self.services.neutralization.processTextAsync(text_content)
|
||||||
result['neutralized_file_name'] = f'neutralized_{filename}'
|
result['neutralized_file_name'] = f'neutralized_{filename}'
|
||||||
# Save neutralized text as file to user files
|
# Save neutralized text as file to user files
|
||||||
if self.services.interfaceDbComponent and result.get('neutralized_text') is not None:
|
if self.services.interfaceDbComponent and result.get('neutralized_text') is not None:
|
||||||
|
|
@ -129,6 +130,11 @@ class NeutralizationPlayground:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Delete a single attribute by ID
|
||||||
|
def deleteAttribute(self, attributeId: str) -> bool:
|
||||||
|
interface = _getNeutralizerInterface(self.currentUser, self.mandateId, self.featureInstanceId)
|
||||||
|
return interface.deleteAttributeById(attributeId)
|
||||||
|
|
||||||
# Cleanup attributes
|
# Cleanup attributes
|
||||||
def cleanAttributes(self, fileId: str) -> bool:
|
def cleanAttributes(self, fileId: str) -> bool:
|
||||||
return self.services.neutralization.deleteNeutralizationAttributes(fileId)
|
return self.services.neutralization.deleteNeutralizationAttributes(fileId)
|
||||||
|
|
@ -192,12 +198,28 @@ class NeutralizationPlayground:
|
||||||
"""Resolve UIDs in neutralized text back to original text"""
|
"""Resolve UIDs in neutralized text back to original text"""
|
||||||
return self.services.neutralization.resolveText(text)
|
return self.services.neutralization.resolveText(text)
|
||||||
|
|
||||||
|
def getSnapshots(self) -> List[DataNeutralizationSnapshot]:
|
||||||
|
"""Return stored neutralization text snapshots."""
|
||||||
|
try:
|
||||||
|
return self.services.neutralization.getSnapshots()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting snapshots: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
def getAttributes(self, fileId: str = None) -> List[DataNeutralizerAttributes]:
|
def getAttributes(self, fileId: str = None) -> List[DataNeutralizerAttributes]:
|
||||||
"""Get neutralization attributes, optionally filtered by file ID"""
|
"""Get neutralization attributes, optionally filtered by file ID"""
|
||||||
try:
|
try:
|
||||||
allAttributes = self.services.neutralization.getAttributes()
|
allAttributes = self.services.neutralization.getAttributes()
|
||||||
if fileId:
|
if fileId:
|
||||||
return [attr for attr in allAttributes if attr.fileId == fileId]
|
want = str(fileId).strip()
|
||||||
|
|
||||||
|
def _matches(a: DataNeutralizerAttributes) -> bool:
|
||||||
|
af = a.fileId
|
||||||
|
if af is None or (isinstance(af, str) and not str(af).strip()):
|
||||||
|
return False
|
||||||
|
return str(af).strip() == want
|
||||||
|
|
||||||
|
return [attr for attr in allAttributes if _matches(attr)]
|
||||||
return allAttributes
|
return allAttributes
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting attributes: {str(e)}")
|
logger.error(f"Error getting attributes: {str(e)}")
|
||||||
|
|
@ -390,7 +412,7 @@ class SharepointProcessor:
|
||||||
textContent = fileContent.decode('utf-8')
|
textContent = fileContent.decode('utf-8')
|
||||||
except UnicodeDecodeError:
|
except UnicodeDecodeError:
|
||||||
textContent = fileContent.decode('latin-1')
|
textContent = fileContent.decode('latin-1')
|
||||||
result = self.services.neutralization.processText(textContent)
|
result = await self.services.neutralization.processTextAsync(textContent)
|
||||||
content_to_upload = (result.get('neutralized_text') or '').encode('utf-8')
|
content_to_upload = (result.get('neutralized_text') or '').encode('utf-8')
|
||||||
|
|
||||||
neutralizedFilename = f"neutralized_{fileInfo['name']}"
|
neutralizedFilename = f"neutralized_{fileInfo['name']}"
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,33 @@ import logging
|
||||||
from modules.auth import limiter, getRequestContext, RequestContext
|
from modules.auth import limiter, getRequestContext, RequestContext
|
||||||
|
|
||||||
# Import interfaces
|
# Import interfaces
|
||||||
from .datamodelFeatureNeutralizer import DataNeutraliserConfig, DataNeutralizerAttributes
|
from .datamodelFeatureNeutralizer import DataNeutraliserConfig, DataNeutralizerAttributes, DataNeutralizationSnapshot
|
||||||
from .neutralizePlayground import NeutralizationPlayground
|
from .neutralizePlayground import NeutralizationPlayground
|
||||||
|
|
||||||
# Configure logger
|
# Configure logger
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _assertFeatureInstancePathMatchesContext(featureInstanceIdFromPath: str, context: RequestContext) -> None:
|
||||||
|
"""Reject path/instance mismatch when request context already carries an instance id."""
|
||||||
|
ctxId = str(context.featureInstanceId).strip() if getattr(context, "featureInstanceId", None) else ""
|
||||||
|
pathId = (featureInstanceIdFromPath or "").strip()
|
||||||
|
if ctxId and pathId and pathId != ctxId:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Feature instance id in URL does not match request context (X-Instance-Id)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetchNeutralizationAttributes(context: RequestContext, fileId: Optional[str]) -> List[DataNeutralizerAttributes]:
|
||||||
|
service = NeutralizationPlayground(
|
||||||
|
context.user,
|
||||||
|
str(context.mandateId) if context.mandateId else "",
|
||||||
|
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None,
|
||||||
|
)
|
||||||
|
return service.getAttributes(fileId)
|
||||||
|
|
||||||
|
|
||||||
# Create router for neutralization endpoints
|
# Create router for neutralization endpoints
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/api/neutralization",
|
prefix="/api/neutralization",
|
||||||
|
|
@ -208,15 +229,9 @@ def get_neutralization_attributes(
|
||||||
) -> List[DataNeutralizerAttributes]:
|
) -> List[DataNeutralizerAttributes]:
|
||||||
"""Get neutralization attributes, optionally filtered by file ID"""
|
"""Get neutralization attributes, optionally filtered by file ID"""
|
||||||
try:
|
try:
|
||||||
service = NeutralizationPlayground(
|
return _fetchNeutralizationAttributes(context, fileId)
|
||||||
context.user,
|
except HTTPException:
|
||||||
str(context.mandateId) if context.mandateId else "",
|
raise
|
||||||
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
|
|
||||||
)
|
|
||||||
attributes = service.getAttributes(fileId)
|
|
||||||
|
|
||||||
return attributes
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting neutralization attributes: {str(e)}")
|
logger.error(f"Error getting neutralization attributes: {str(e)}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -224,6 +239,72 @@ def get_neutralization_attributes(
|
||||||
detail=f"Error getting neutralization attributes: {str(e)}"
|
detail=f"Error getting neutralization attributes: {str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{feature_instance_id}/attributes", response_model=List[DataNeutralizerAttributes])
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
def get_neutralization_attributes_scoped(
|
||||||
|
request: Request,
|
||||||
|
feature_instance_id: str = Path(..., description="Workspace / feature instance id (must match X-Instance-Id when set)"),
|
||||||
|
fileId: Optional[str] = Query(None, description="Filter by file ID"),
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
) -> List[DataNeutralizerAttributes]:
|
||||||
|
"""Same as GET /attributes; path includes instance id for workspace UI compatibility."""
|
||||||
|
_assertFeatureInstancePathMatchesContext(feature_instance_id, context)
|
||||||
|
try:
|
||||||
|
return _fetchNeutralizationAttributes(context, fileId)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting neutralization attributes: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Error getting neutralization attributes: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/snapshots", response_model=List[DataNeutralizationSnapshot])
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
def get_neutralization_snapshots(
|
||||||
|
request: Request,
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
) -> List[DataNeutralizationSnapshot]:
|
||||||
|
"""Return neutralized-text snapshots (full text with placeholders) for the current feature instance."""
|
||||||
|
try:
|
||||||
|
service = NeutralizationPlayground(
|
||||||
|
context.user,
|
||||||
|
str(context.mandateId) if context.mandateId else "",
|
||||||
|
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None,
|
||||||
|
)
|
||||||
|
return service.getSnapshots()
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting neutralization snapshots: {e}")
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{feature_instance_id}/snapshots", response_model=List[DataNeutralizationSnapshot])
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
def get_neutralization_snapshots_scoped(
|
||||||
|
request: Request,
|
||||||
|
feature_instance_id: str = Path(..., description="Workspace instance id (must match X-Instance-Id when set)"),
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
) -> List[DataNeutralizationSnapshot]:
|
||||||
|
"""Same as GET /snapshots; path includes instance id for workspace UI (explicit scope)."""
|
||||||
|
_assertFeatureInstancePathMatchesContext(feature_instance_id, context)
|
||||||
|
try:
|
||||||
|
service = NeutralizationPlayground(
|
||||||
|
context.user,
|
||||||
|
str(context.mandateId) if context.mandateId else "",
|
||||||
|
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None,
|
||||||
|
)
|
||||||
|
return service.getSnapshots()
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting neutralization snapshots (scoped): {e}")
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/process-sharepoint", response_model=Dict[str, Any])
|
@router.post("/process-sharepoint", response_model=Dict[str, Any])
|
||||||
@limiter.limit("5/minute")
|
@limiter.limit("5/minute")
|
||||||
async def process_sharepoint_files(
|
async def process_sharepoint_files(
|
||||||
|
|
@ -317,6 +398,108 @@ def get_neutralization_stats(
|
||||||
detail=f"Error getting neutralization stats: {str(e)}"
|
detail=f"Error getting neutralization stats: {str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _deleteSingleNeutralizationAttribute(context: RequestContext, attributeId: str) -> Dict[str, str]:
|
||||||
|
service = NeutralizationPlayground(
|
||||||
|
context.user,
|
||||||
|
str(context.mandateId) if context.mandateId else "",
|
||||||
|
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None,
|
||||||
|
)
|
||||||
|
success = service.deleteAttribute(attributeId)
|
||||||
|
if success:
|
||||||
|
return {"message": f"Attribute {attributeId} deleted"}
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Attribute {attributeId} not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/attributes/single/{attributeId}", response_model=Dict[str, str])
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
def deleteAttribute(
|
||||||
|
request: Request,
|
||||||
|
attributeId: str = Path(..., description="Attribute ID to delete"),
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
) -> Dict[str, str]:
|
||||||
|
"""Delete a single neutralization attribute by ID."""
|
||||||
|
try:
|
||||||
|
return _deleteSingleNeutralizationAttribute(context, attributeId)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting attribute: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{feature_instance_id}/attributes/single/{attributeId}", response_model=Dict[str, str])
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
def deleteAttributeScoped(
|
||||||
|
request: Request,
|
||||||
|
feature_instance_id: str = Path(..., description="Workspace / feature instance id"),
|
||||||
|
attributeId: str = Path(..., description="Attribute ID to delete"),
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
) -> Dict[str, str]:
|
||||||
|
"""Same as DELETE /attributes/single/{attributeId}; path includes instance id for workspace UI."""
|
||||||
|
_assertFeatureInstancePathMatchesContext(feature_instance_id, context)
|
||||||
|
try:
|
||||||
|
return _deleteSingleNeutralizationAttribute(context, attributeId)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting attribute: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def _retriggerNeutralizationBody(context: RequestContext, fileId: str) -> Dict[str, str]:
|
||||||
|
if not fileId:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="fileId is required",
|
||||||
|
)
|
||||||
|
service = NeutralizationPlayground(
|
||||||
|
context.user,
|
||||||
|
str(context.mandateId) if context.mandateId else "",
|
||||||
|
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None,
|
||||||
|
)
|
||||||
|
service.cleanupFileAttributes(fileId)
|
||||||
|
return {"message": f"Neutralization re-triggered for file {fileId}", "fileId": fileId}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/retrigger", response_model=Dict[str, str])
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
def retriggerNeutralization(
|
||||||
|
request: Request,
|
||||||
|
retriggerData: Dict[str, str] = Body(...),
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
) -> Dict[str, str]:
|
||||||
|
"""Re-trigger neutralization for a specific file."""
|
||||||
|
try:
|
||||||
|
return _retriggerNeutralizationBody(context, retriggerData.get("fileId", ""))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error re-triggering neutralization: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{feature_instance_id}/retrigger", response_model=Dict[str, str])
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
def retriggerNeutralizationScoped(
|
||||||
|
request: Request,
|
||||||
|
feature_instance_id: str = Path(..., description="Workspace / feature instance id"),
|
||||||
|
retriggerData: Dict[str, str] = Body(...),
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
) -> Dict[str, str]:
|
||||||
|
"""Same as POST /retrigger; path includes instance id for workspace UI compatibility."""
|
||||||
|
_assertFeatureInstancePathMatchesContext(feature_instance_id, context)
|
||||||
|
try:
|
||||||
|
return _retriggerNeutralizationBody(context, retriggerData.get("fileId", ""))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error re-triggering neutralization: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/attributes/{fileId}", response_model=Dict[str, str])
|
@router.delete("/attributes/{fileId}", response_model=Dict[str, str])
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
def cleanup_file_attributes(
|
def cleanup_file_attributes(
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,12 @@ class NeutralizationService:
|
||||||
mandateId=serviceCenter.mandateId or dbApp.mandateId,
|
mandateId=serviceCenter.mandateId or dbApp.mandateId,
|
||||||
featureInstanceId=getattr(serviceCenter, 'featureInstanceId', None) or getattr(dbApp, 'featureInstanceId', None)
|
featureInstanceId=getattr(serviceCenter, 'featureInstanceId', None) or getattr(dbApp, 'featureInstanceId', None)
|
||||||
)
|
)
|
||||||
|
elif serviceCenter and getattr(serviceCenter, "user", None):
|
||||||
|
self.interfaceNeutralizer = getNeutralizerInterface(
|
||||||
|
currentUser=serviceCenter.user,
|
||||||
|
mandateId=getattr(serviceCenter, 'mandateId', None) or getattr(serviceCenter, 'mandate_id', None),
|
||||||
|
featureInstanceId=getattr(serviceCenter, 'featureInstanceId', None) or getattr(serviceCenter, 'feature_instance_id', None),
|
||||||
|
)
|
||||||
|
|
||||||
namesList = NamesToParse if isinstance(NamesToParse, list) else []
|
namesList = NamesToParse if isinstance(NamesToParse, list) else []
|
||||||
self.NamesToParse = namesList
|
self.NamesToParse = namesList
|
||||||
|
|
@ -82,11 +88,213 @@ class NeutralizationService:
|
||||||
|
|
||||||
# Public API: process text or file
|
# Public API: process text or file
|
||||||
|
|
||||||
def processText(self, text: str) -> Dict[str, Any]:
|
_NEUT_INSTRUCTION = (
|
||||||
"""Neutralize a raw text string and return a standard result dict."""
|
"Analyze the following text and identify ALL sensitive content that must be neutralized:\n"
|
||||||
result = self._neutralizeText(text, 'text')
|
"1. Personal data (PII): names of persons, email addresses, phone numbers, "
|
||||||
self._persistAttributes(result.get('mapping', {}), None)
|
"physical addresses, ID numbers, dates of birth, financial data (IBAN, account numbers), "
|
||||||
return result
|
"social security numbers\n"
|
||||||
|
"2. Protected business logic: proprietary algorithms, trade secrets, confidential "
|
||||||
|
"processes, internal procedures, code snippets that reveal implementation details\n"
|
||||||
|
"3. Named entities: company names, product names, project names, brand names\n\n"
|
||||||
|
"Return ONLY a JSON array (no markdown, no explanation):\n"
|
||||||
|
'[{"text":"exact substring","type":"name|email|phone|address|id|financial|logic|company|product|location|other"}]\n\n'
|
||||||
|
"Rules:\n"
|
||||||
|
"- Every entry's 'text' must be an exact, verbatim substring of the input.\n"
|
||||||
|
"- Do NOT include generic words, common language constructs or non-sensitive terms.\n"
|
||||||
|
"- If nothing is sensitive, return [].\n\n"
|
||||||
|
)
|
||||||
|
_BYTES_PER_TOKEN = 3
|
||||||
|
_SELECTOR_MAX_RATIO = 0.8
|
||||||
|
_CHUNK_SAFETY_MARGIN = 0.9
|
||||||
|
|
||||||
|
def _resolveNeutModel(self):
|
||||||
|
"""Query the model registry for the best NEUTRALIZATION_TEXT model.
|
||||||
|
Returns the model object (with contextLength etc.) or None."""
|
||||||
|
try:
|
||||||
|
from modules.aicore.aicoreModelRegistry import modelRegistry
|
||||||
|
from modules.aicore.aicoreModelSelector import modelSelector as _modSel
|
||||||
|
from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum
|
||||||
|
|
||||||
|
_models = modelRegistry.getAvailableModels()
|
||||||
|
_opts = AiCallOptions(operationType=OperationTypeEnum.NEUTRALIZATION_TEXT)
|
||||||
|
_failover = _modSel.getFailoverModelList("x", "", _opts, _models)
|
||||||
|
return _failover[0] if _failover else None
|
||||||
|
except Exception as _e:
|
||||||
|
logger.warning(f"_resolveNeutModel failed: {_e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _calcMaxChunkChars(self, model) -> int:
|
||||||
|
"""Derive the maximum text-chunk size (in characters) from the selected
|
||||||
|
model's contextLength, mirroring the rules in aicoreModelSelector:
|
||||||
|
promptTokens = promptBytes / 3 must be <= contextLength * 0.8
|
||||||
|
Subtract the instruction overhead and apply a safety margin."""
|
||||||
|
if not model or getattr(model, 'contextLength', 0) <= 0:
|
||||||
|
return 5000
|
||||||
|
_instructionBytes = len(self._NEUT_INSTRUCTION.encode('utf-8')) + 30
|
||||||
|
_maxPromptBytes = int(model.contextLength * self._SELECTOR_MAX_RATIO * self._BYTES_PER_TOKEN)
|
||||||
|
_maxChunkChars = int((_maxPromptBytes - _instructionBytes) * self._CHUNK_SAFETY_MARGIN)
|
||||||
|
return max(_maxChunkChars, 500)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _splitTextIntoChunks(text: str, maxChars: int) -> List[str]:
|
||||||
|
"""Split *text* into chunks of at most *maxChars*, preferring paragraph
|
||||||
|
then sentence boundaries so that the LLM sees coherent blocks."""
|
||||||
|
if len(text) <= maxChars:
|
||||||
|
return [text]
|
||||||
|
|
||||||
|
chunks: List[str] = []
|
||||||
|
remaining = text
|
||||||
|
while remaining:
|
||||||
|
if len(remaining) <= maxChars:
|
||||||
|
chunks.append(remaining)
|
||||||
|
break
|
||||||
|
_cut = maxChars
|
||||||
|
_para = remaining.rfind("\n\n", 0, _cut)
|
||||||
|
if _para > maxChars // 3:
|
||||||
|
_cut = _para + 2
|
||||||
|
else:
|
||||||
|
_nl = remaining.rfind("\n", 0, _cut)
|
||||||
|
if _nl > maxChars // 3:
|
||||||
|
_cut = _nl + 1
|
||||||
|
else:
|
||||||
|
_dot = remaining.rfind(". ", 0, _cut)
|
||||||
|
if _dot > maxChars // 3:
|
||||||
|
_cut = _dot + 2
|
||||||
|
else:
|
||||||
|
_sp = remaining.rfind(" ", 0, _cut)
|
||||||
|
if _sp > maxChars // 3:
|
||||||
|
_cut = _sp + 1
|
||||||
|
chunks.append(remaining[:_cut])
|
||||||
|
remaining = remaining[_cut:]
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
async def _analyseChunk(self, aiService, chunkText: str) -> List[dict]:
|
||||||
|
"""Send one chunk to the NEUTRALIZATION_TEXT model, return raw findings list."""
|
||||||
|
from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum
|
||||||
|
|
||||||
|
_prompt = self._NEUT_INSTRUCTION + "Text to analyze:\n---\n" + chunkText + "\n---"
|
||||||
|
_request = AiCallRequest(
|
||||||
|
prompt=_prompt,
|
||||||
|
options=AiCallOptions(operationType=OperationTypeEnum.NEUTRALIZATION_TEXT),
|
||||||
|
)
|
||||||
|
_response = await aiService.callAi(_request)
|
||||||
|
if not _response or not getattr(_response, 'content', None):
|
||||||
|
raise RuntimeError(
|
||||||
|
"Neutralization AI call returned no response "
|
||||||
|
"(no model available for NEUTRALIZATION_TEXT?)"
|
||||||
|
)
|
||||||
|
if getattr(_response, 'errorCount', 0) > 0 or getattr(_response, 'modelName', '') == 'error':
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Neutralization AI call failed: {_response.content}"
|
||||||
|
)
|
||||||
|
_content = _response.content.strip()
|
||||||
|
if _content.startswith("```"):
|
||||||
|
_content = _content.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
|
||||||
|
try:
|
||||||
|
return json.loads(_content)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
_bracket = _content.find("[")
|
||||||
|
if _bracket >= 0:
|
||||||
|
try:
|
||||||
|
return json.loads(_content[_bracket:])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def processTextAsync(self, text: str, fileId: Optional[str] = None) -> Dict[str, Any]:
|
||||||
|
"""AI-powered text neutralization with automatic chunking.
|
||||||
|
|
||||||
|
If *text* exceeds the safe token budget for the neutralization model
|
||||||
|
it is split into smaller chunks, each analysed separately. Findings
|
||||||
|
are merged and de-duplicated before placeholder replacement.
|
||||||
|
|
||||||
|
Regex patterns run as a supplementary pass to catch anything the
|
||||||
|
model missed.
|
||||||
|
"""
|
||||||
|
import uuid as _uuid
|
||||||
|
|
||||||
|
aiService = None
|
||||||
|
if self._getService:
|
||||||
|
try:
|
||||||
|
aiService = self._getService("ai")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
aiMapping: Dict[str, str] = {}
|
||||||
|
|
||||||
|
if not aiService or not hasattr(aiService, 'callAi'):
|
||||||
|
raise RuntimeError("Neutralization requires an AI service but none is available")
|
||||||
|
|
||||||
|
if text.strip():
|
||||||
|
_neutModel = self._resolveNeutModel()
|
||||||
|
_maxChunkChars = self._calcMaxChunkChars(_neutModel)
|
||||||
|
logger.info(
|
||||||
|
f"processTextAsync: model={getattr(_neutModel, 'name', '?')}, "
|
||||||
|
f"contextLength={getattr(_neutModel, 'contextLength', '?')} tokens, "
|
||||||
|
f"maxChunkChars={_maxChunkChars}"
|
||||||
|
)
|
||||||
|
|
||||||
|
_chunks = self._splitTextIntoChunks(text, _maxChunkChars)
|
||||||
|
if len(_chunks) > 1:
|
||||||
|
logger.info(
|
||||||
|
f"processTextAsync: text ({len(text)} chars) "
|
||||||
|
f"split into {len(_chunks)} chunk(s) of max {_maxChunkChars} chars"
|
||||||
|
)
|
||||||
|
|
||||||
|
for _chunkIdx, _chunkText in enumerate(_chunks):
|
||||||
|
_findings = await self._analyseChunk(aiService, _chunkText)
|
||||||
|
if not isinstance(_findings, list):
|
||||||
|
continue
|
||||||
|
for _f in _findings:
|
||||||
|
if not isinstance(_f, dict):
|
||||||
|
continue
|
||||||
|
_origText = _f.get("text", "")
|
||||||
|
_patType = _f.get("type", "other").lower()
|
||||||
|
if not _origText or _origText not in text:
|
||||||
|
continue
|
||||||
|
if _origText in aiMapping:
|
||||||
|
continue
|
||||||
|
_uid = str(_uuid.uuid4())
|
||||||
|
_placeholder = f"[{_patType}.{_uid}]"
|
||||||
|
aiMapping[_origText] = _placeholder
|
||||||
|
|
||||||
|
logger.info(f"AI neutralization found {len(aiMapping)} item(s)"
|
||||||
|
+ (f" across {len(_chunks)} chunk(s)" if len(_chunks) > 1 else ""))
|
||||||
|
|
||||||
|
neutralizedText = text
|
||||||
|
for _orig, _ph in sorted(aiMapping.items(), key=lambda x: -len(x[0])):
|
||||||
|
neutralizedText = neutralizedText.replace(_orig, _ph)
|
||||||
|
|
||||||
|
regexMapping: Dict[str, str] = {}
|
||||||
|
finalText = neutralizedText
|
||||||
|
|
||||||
|
allMapping = {**aiMapping, **regexMapping}
|
||||||
|
if allMapping:
|
||||||
|
_loop = asyncio.get_event_loop()
|
||||||
|
await _loop.run_in_executor(
|
||||||
|
None, self._persistAttributes, allMapping, fileId
|
||||||
|
)
|
||||||
|
logger.debug(f"processTextAsync: {len(allMapping)} attribute(s) persisted")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'neutralized_text': finalText,
|
||||||
|
'mapping': allMapping,
|
||||||
|
'attributes': [
|
||||||
|
NeutralizationAttribute(original=k, placeholder=v)
|
||||||
|
for k, v in allMapping.items()
|
||||||
|
],
|
||||||
|
'processed_info': {'type': 'text', 'ai_findings': len(aiMapping), 'regex_findings': len(regexMapping)},
|
||||||
|
}
|
||||||
|
|
||||||
|
def processText(self, text: str, fileId: Optional[str] = None) -> Dict[str, Any]:
|
||||||
|
"""Sync wrapper around processTextAsync. Propagates errors."""
|
||||||
|
try:
|
||||||
|
return asyncio.run(self.processTextAsync(text, fileId))
|
||||||
|
except RuntimeError as _re:
|
||||||
|
if "cannot be called from a running event loop" in str(_re):
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
return loop.run_until_complete(self.processTextAsync(text, fileId))
|
||||||
|
raise
|
||||||
|
|
||||||
def processFile(self, fileId: str) -> Dict[str, Any]:
|
def processFile(self, fileId: str) -> Dict[str, Any]:
|
||||||
"""Neutralize a file referenced by its fileId using component interface.
|
"""Neutralize a file referenced by its fileId using component interface.
|
||||||
|
|
@ -153,8 +361,7 @@ class NeutralizationService:
|
||||||
raise ValueError("Unable to decode file content as text.")
|
raise ValueError("Unable to decode file content as text.")
|
||||||
textContent = decoded
|
textContent = decoded
|
||||||
|
|
||||||
result = self._neutralizeText(textContent, textType)
|
result = self.processText(textContent, fileId)
|
||||||
self._persistAttributes(result.get('mapping', {}), fileId)
|
|
||||||
if fileName:
|
if fileName:
|
||||||
result['neutralized_file_name'] = f"neutralized_{fileName}"
|
result['neutralized_file_name'] = f"neutralized_{fileName}"
|
||||||
result['file_id'] = fileId
|
result['file_id'] = fileId
|
||||||
|
|
@ -203,6 +410,89 @@ class NeutralizationService:
|
||||||
'processed_info': {'type': 'binary', 'status': 'error', 'error': str(e)}
|
'processed_info': {'type': 'binary', 'status': 'error', 'error': str(e)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def processImageAsync(self, imageBytes: bytes, fileName: str, mimeType: str = "image/png") -> Dict[str, Any]:
|
||||||
|
"""Analyze image via internal vision model to check for sensitive content.
|
||||||
|
|
||||||
|
Returns dict with:
|
||||||
|
- 'status': 'ok' | 'blocked' | 'error'
|
||||||
|
- 'hasSensitiveContent': bool
|
||||||
|
- 'analysis': str (model's analysis text, if available)
|
||||||
|
- 'processed_info': dict with details
|
||||||
|
|
||||||
|
Uses NEUTRALIZATION_IMAGE operation type → only internal Private-LLM models.
|
||||||
|
If no internal model available → returns 'blocked'.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
try:
|
||||||
|
aiService = None
|
||||||
|
if self._getService:
|
||||||
|
try:
|
||||||
|
aiService = self._getService("ai")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not aiService or not hasattr(aiService, 'callAi'):
|
||||||
|
logger.warning(f"processImage: AI service not available — blocking image '{fileName}'")
|
||||||
|
return {
|
||||||
|
'status': 'blocked',
|
||||||
|
'hasSensitiveContent': True,
|
||||||
|
'analysis': '',
|
||||||
|
'processed_info': {'type': 'image', 'status': 'blocked', 'reason': 'AI service unavailable'}
|
||||||
|
}
|
||||||
|
|
||||||
|
from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum
|
||||||
|
|
||||||
|
_b64Data = base64.b64encode(imageBytes).decode('utf-8')
|
||||||
|
_dataUrl = f"data:{mimeType};base64,{_b64Data}"
|
||||||
|
|
||||||
|
_prompt = (
|
||||||
|
"Analyze this image for personally identifiable information (PII). "
|
||||||
|
"Check for: names, addresses, phone numbers, email addresses, ID numbers, "
|
||||||
|
"faces, signatures, handwritten text, license plates, financial data. "
|
||||||
|
"Respond with JSON: {\"hasPII\": true/false, \"findings\": [\"...\"]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
_request = AiCallRequest(
|
||||||
|
prompt=_prompt,
|
||||||
|
options=AiCallOptions(operationType=OperationTypeEnum.NEUTRALIZATION_IMAGE),
|
||||||
|
messages=[{"role": "user", "content": [
|
||||||
|
{"type": "text", "text": _prompt},
|
||||||
|
{"type": "image_url", "image_url": {"url": _dataUrl}},
|
||||||
|
]}],
|
||||||
|
)
|
||||||
|
|
||||||
|
_response = await aiService.callAi(_request)
|
||||||
|
|
||||||
|
_hasPII = False
|
||||||
|
_analysis = _response.content if _response and hasattr(_response, 'content') else ''
|
||||||
|
if _analysis:
|
||||||
|
_lowerAnalysis = _analysis.lower()
|
||||||
|
if '"haspii": true' in _lowerAnalysis or '"haspii":true' in _lowerAnalysis:
|
||||||
|
_hasPII = True
|
||||||
|
|
||||||
|
return {
|
||||||
|
'status': 'blocked' if _hasPII else 'ok',
|
||||||
|
'hasSensitiveContent': _hasPII,
|
||||||
|
'analysis': _analysis,
|
||||||
|
'processed_info': {'type': 'image', 'status': 'blocked' if _hasPII else 'ok', 'fileName': fileName}
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"processImage failed for '{fileName}': {e}")
|
||||||
|
return {
|
||||||
|
'status': 'blocked',
|
||||||
|
'hasSensitiveContent': True,
|
||||||
|
'analysis': '',
|
||||||
|
'processed_info': {'type': 'image', 'status': 'error', 'error': str(e)}
|
||||||
|
}
|
||||||
|
|
||||||
|
def processImage(self, imageBytes: bytes, fileName: str, mimeType: str = "image/png") -> Dict[str, Any]:
|
||||||
|
"""Sync wrapper for processImageAsync. Uses asyncio.run when no event loop is running."""
|
||||||
|
import asyncio
|
||||||
|
try:
|
||||||
|
return asyncio.run(self.processImageAsync(imageBytes, fileName, mimeType))
|
||||||
|
except RuntimeError:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
return loop.run_until_complete(self.processImageAsync(imageBytes, fileName, mimeType))
|
||||||
|
|
||||||
def resolveText(self, text: str) -> str:
|
def resolveText(self, text: str) -> str:
|
||||||
if not self.interfaceNeutralizer:
|
if not self.interfaceNeutralizer:
|
||||||
return text
|
return text
|
||||||
|
|
@ -236,6 +526,22 @@ class NeutralizationService:
|
||||||
return False
|
return False
|
||||||
return self.interfaceNeutralizer.deleteNeutralizationAttributes(fileId)
|
return self.interfaceNeutralizer.deleteNeutralizationAttributes(fileId)
|
||||||
|
|
||||||
|
def getSnapshots(self):
|
||||||
|
if not self.interfaceNeutralizer:
|
||||||
|
return []
|
||||||
|
return self.interfaceNeutralizer.getSnapshots()
|
||||||
|
|
||||||
|
def clearSnapshots(self) -> int:
|
||||||
|
if not self.interfaceNeutralizer:
|
||||||
|
return 0
|
||||||
|
return self.interfaceNeutralizer.clearSnapshots()
|
||||||
|
|
||||||
|
def saveSnapshot(self, sourceLabel: str, neutralizedText: str, placeholderCount: int = 0):
|
||||||
|
if not self.interfaceNeutralizer:
|
||||||
|
logger.warning("saveSnapshot: interfaceNeutralizer is None — snapshot not stored")
|
||||||
|
return None
|
||||||
|
return self.interfaceNeutralizer.createSnapshot(sourceLabel, neutralizedText, placeholderCount)
|
||||||
|
|
||||||
def _persistAttributes(self, mapping: Dict[str, str], fileId: Optional[str]) -> None:
|
def _persistAttributes(self, mapping: Dict[str, str], fileId: Optional[str]) -> None:
|
||||||
"""Persist mapping to DB for resolve to work. mapping: originalText -> placeholder e.g. '[email.uuid]'"""
|
"""Persist mapping to DB for resolve to work. mapping: originalText -> placeholder e.g. '[email.uuid]'"""
|
||||||
if not self.interfaceNeutralizer or not mapping:
|
if not self.interfaceNeutralizer or not mapping:
|
||||||
|
|
@ -295,10 +601,22 @@ class NeutralizationService:
|
||||||
p = part if isinstance(part, dict) else part.model_dump() if hasattr(part, 'model_dump') else part
|
p = part if isinstance(part, dict) else part.model_dump() if hasattr(part, 'model_dump') else part
|
||||||
type_group = p.get('typeGroup', '')
|
type_group = p.get('typeGroup', '')
|
||||||
data = p.get('data', '')
|
data = p.get('data', '')
|
||||||
if type_group in ('binary', 'image') or not (data and str(data).strip()):
|
if type_group == 'binary' or not (data and str(data).strip()):
|
||||||
neutralized_parts.append(part)
|
neutralized_parts.append(part)
|
||||||
continue
|
continue
|
||||||
nr = self._neutralizeText(str(data), 'text' if type_group != 'table' else 'csv')
|
if type_group == 'image':
|
||||||
|
import base64 as _b64img
|
||||||
|
try:
|
||||||
|
_imgBytes = _b64img.b64decode(str(data))
|
||||||
|
_imgResult = await self.processImageAsync(_imgBytes, fileName)
|
||||||
|
if _imgResult.get("status") == "ok":
|
||||||
|
neutralized_parts.append(part)
|
||||||
|
else:
|
||||||
|
logger.warning(f"Image part blocked in binary file '{fileName}' (PII detected), removing")
|
||||||
|
except Exception as _imgErr:
|
||||||
|
logger.warning(f"Image check failed in binary file '{fileName}': {_imgErr}, removing (fail-safe)")
|
||||||
|
continue
|
||||||
|
nr = await self.processTextAsync(str(data), fileId)
|
||||||
proc = nr.get('processed_info', {}) or {}
|
proc = nr.get('processed_info', {}) or {}
|
||||||
if isinstance(proc, dict) and proc.get('type') == 'error':
|
if isinstance(proc, dict) and proc.get('type') == 'error':
|
||||||
neutralization_error = proc.get('error', 'Neutralization failed')
|
neutralization_error = proc.get('error', 'Neutralization failed')
|
||||||
|
|
@ -307,7 +625,6 @@ class NeutralizationService:
|
||||||
all_mapping.update(mapping)
|
all_mapping.update(mapping)
|
||||||
new_part = {**p, 'data': neu_text}
|
new_part = {**p, 'data': neu_text}
|
||||||
neutralized_parts.append(new_part)
|
neutralized_parts.append(new_part)
|
||||||
self._persistAttributes(all_mapping, fileId)
|
|
||||||
|
|
||||||
# 3. PDF: Use in-place only; no fallback to render
|
# 3. PDF: Use in-place only; no fallback to render
|
||||||
if mimeType == "application/pdf":
|
if mimeType == "application/pdf":
|
||||||
|
|
@ -451,10 +768,31 @@ class NeutralizationService:
|
||||||
|
|
||||||
# Helper functions
|
# Helper functions
|
||||||
|
|
||||||
|
def _neutralizeTextLight(self, text: str) -> Dict[str, Any]:
|
||||||
|
"""Regex-only supplementary pass using already-initialised processors.
|
||||||
|
|
||||||
|
Unlike ``_neutralizeText`` this does **no** DB I/O
|
||||||
|
(``_reloadNamesFromConfig`` is skipped) so it is safe to call from
|
||||||
|
an async context without blocking the event-loop or risking a
|
||||||
|
DB-connection-pool deadlock during parallel document processing.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data, mapping, replaced_fields, processed_info = self.textProcessor.processTextContent(text)
|
||||||
|
neutralized_text = str(data)
|
||||||
|
attributes = [NeutralizationAttribute(original=k, placeholder=v) for k, v in mapping.items()]
|
||||||
|
return NeutralizationResult(
|
||||||
|
neutralized_text=neutralized_text,
|
||||||
|
mapping=mapping,
|
||||||
|
attributes=attributes,
|
||||||
|
processed_info=processed_info,
|
||||||
|
).model_dump()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"_neutralizeTextLight error: {e}")
|
||||||
|
return {'neutralized_text': text, 'mapping': {}, 'attributes': [], 'processed_info': {'type': 'error', 'error': str(e)}}
|
||||||
|
|
||||||
def _neutralizeText(self, text: str, textType: str = None) -> Dict[str, Any]:
|
def _neutralizeText(self, text: str, textType: str = None) -> Dict[str, Any]:
|
||||||
"""Process text and return unified dict for API consumption."""
|
"""Process text and return unified dict for API consumption."""
|
||||||
try:
|
try:
|
||||||
# Reload names from config before processing to ensure we have the latest names
|
|
||||||
self._reloadNamesFromConfig()
|
self._reloadNamesFromConfig()
|
||||||
|
|
||||||
# Auto-detect content type if not provided
|
# Auto-detect content type if not provided
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ Implements a general Swiss architecture planning data model.
|
||||||
from typing import List, Dict, Any, Optional, ForwardRef
|
from typing import List, Dict, Any, Optional, ForwardRef
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from modules.datamodels.datamodelBase import PowerOnModel
|
||||||
from modules.shared.attributeUtils import registerModelLabels
|
from modules.shared.attributeUtils import registerModelLabels
|
||||||
from modules.shared.timeUtils import getUtcTimestamp
|
from modules.shared.timeUtils import getUtcTimestamp
|
||||||
import uuid
|
import uuid
|
||||||
|
|
@ -178,7 +179,7 @@ class Dokument(BaseModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Kontext(BaseModel):
|
class Kontext(PowerOnModel):
|
||||||
"""Supporting data object for flexible additional information."""
|
"""Supporting data object for flexible additional information."""
|
||||||
id: str = Field(
|
id: str = Field(
|
||||||
default_factory=lambda: str(uuid.uuid4()),
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
|
|
@ -248,7 +249,7 @@ class Land(BaseModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Kanton(BaseModel):
|
class Kanton(PowerOnModel):
|
||||||
"""Cantonal level administrative entity."""
|
"""Cantonal level administrative entity."""
|
||||||
id: str = Field(
|
id: str = Field(
|
||||||
default_factory=lambda: str(uuid.uuid4()),
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
|
|
@ -368,7 +369,7 @@ class Gemeinde(BaseModel):
|
||||||
ParzelleRef = ForwardRef('Parzelle')
|
ParzelleRef = ForwardRef('Parzelle')
|
||||||
|
|
||||||
|
|
||||||
class Parzelle(BaseModel):
|
class Parzelle(PowerOnModel):
|
||||||
"""Represents a plot with all building law properties."""
|
"""Represents a plot with all building law properties."""
|
||||||
id: str = Field(
|
id: str = Field(
|
||||||
default_factory=lambda: str(uuid.uuid4()),
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
|
|
@ -594,7 +595,7 @@ class Parzelle(BaseModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Projekt(BaseModel):
|
class Projekt(PowerOnModel):
|
||||||
"""Core object representing a construction project."""
|
"""Core object representing a construction project."""
|
||||||
id: str = Field(
|
id: str = Field(
|
||||||
default_factory=lambda: str(uuid.uuid4()),
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
|
|
|
||||||
|
|
@ -39,52 +39,57 @@ RESOURCE_OBJECTS = [
|
||||||
# Template roles for this feature with AccessRules
|
# Template roles for this feature with AccessRules
|
||||||
# IMPORTANT: item uses vollqualifizierte ObjectKeys (gemäss Navigation-API-Konzept)
|
# IMPORTANT: item uses vollqualifizierte ObjectKeys (gemäss Navigation-API-Konzept)
|
||||||
TEMPLATE_ROLES = [
|
TEMPLATE_ROLES = [
|
||||||
|
{
|
||||||
|
"roleLabel": "realestate-viewer",
|
||||||
|
"description": {
|
||||||
|
"en": "Real Estate Viewer - View property information (read-only)",
|
||||||
|
"de": "Immobilien-Betrachter - Immobilien-Informationen einsehen (nur lesen)",
|
||||||
|
"fr": "Visualiseur immobilier - Consulter les informations immobilières (lecture seule)",
|
||||||
|
},
|
||||||
|
"accessRules": [
|
||||||
|
{"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True},
|
||||||
|
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roleLabel": "realestate-user",
|
||||||
|
"description": {
|
||||||
|
"en": "Real Estate User - Create and manage own property records",
|
||||||
|
"de": "Immobilien-Benutzer - Eigene Immobilien-Daten erstellen und verwalten",
|
||||||
|
"fr": "Utilisateur immobilier - Créer et gérer ses propres données immobilières",
|
||||||
|
},
|
||||||
|
"accessRules": [
|
||||||
|
{"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True},
|
||||||
|
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
|
||||||
|
{"context": "RESOURCE", "item": "resource.feature.realestate.project.create", "view": True},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"roleLabel": "realestate-admin",
|
"roleLabel": "realestate-admin",
|
||||||
"description": {
|
"description": {
|
||||||
"en": "Real Estate Administrator - Full access to all property data and settings",
|
"en": "Real Estate Administrator - Full access to all property data and settings",
|
||||||
"de": "Immobilien-Administrator - Vollzugriff auf alle Immobiliendaten und Einstellungen",
|
"de": "Immobilien-Administrator - Vollzugriff auf alle Immobiliendaten und Einstellungen",
|
||||||
"fr": "Administrateur immobilier - Accès complet aux données et paramètres"
|
"fr": "Administrateur immobilier - Accès complet aux données et paramètres",
|
||||||
},
|
},
|
||||||
"accessRules": [
|
"accessRules": [
|
||||||
# Full UI access (all views including admin views)
|
|
||||||
{"context": "UI", "item": None, "view": True},
|
{"context": "UI", "item": None, "view": True},
|
||||||
# Full DATA access
|
|
||||||
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"},
|
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"},
|
||||||
# Admin resources
|
|
||||||
{"context": "RESOURCE", "item": "resource.feature.realestate.project.create", "view": True},
|
{"context": "RESOURCE", "item": "resource.feature.realestate.project.create", "view": True},
|
||||||
{"context": "RESOURCE", "item": "resource.feature.realestate.project.delete", "view": True},
|
{"context": "RESOURCE", "item": "resource.feature.realestate.project.delete", "view": True},
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"roleLabel": "realestate-manager",
|
"roleLabel": "realestate-manager",
|
||||||
"description": {
|
"description": {
|
||||||
"en": "Real Estate Manager - Manage properties and tenants",
|
"en": "Real Estate Manager - Manage properties and tenants",
|
||||||
"de": "Immobilien-Verwalter - Immobilien und Mieter verwalten",
|
"de": "Immobilien-Verwalter - Immobilien und Mieter verwalten",
|
||||||
"fr": "Gestionnaire immobilier - Gérer les propriétés et locataires"
|
"fr": "Gestionnaire immobilier - Gérer les propriétés et locataires",
|
||||||
},
|
},
|
||||||
"accessRules": [
|
"accessRules": [
|
||||||
# UI access to map view
|
|
||||||
{"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True},
|
{"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True},
|
||||||
# Group-level DATA access
|
|
||||||
{"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "g"},
|
{"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "g"},
|
||||||
# Resource: create projects
|
|
||||||
{"context": "RESOURCE", "item": "resource.feature.realestate.project.create", "view": True},
|
{"context": "RESOURCE", "item": "resource.feature.realestate.project.create", "view": True},
|
||||||
]
|
],
|
||||||
},
|
|
||||||
{
|
|
||||||
"roleLabel": "realestate-viewer",
|
|
||||||
"description": {
|
|
||||||
"en": "Real Estate Viewer - View property information",
|
|
||||||
"de": "Immobilien-Betrachter - Immobilien-Informationen einsehen",
|
|
||||||
"fr": "Visualiseur immobilier - Consulter les informations immobilières"
|
|
||||||
},
|
|
||||||
"accessRules": [
|
|
||||||
# UI access to map view (read-only)
|
|
||||||
{"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True},
|
|
||||||
# Read-only DATA access (my records)
|
|
||||||
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ from pydantic import BaseModel, Field
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
from modules.datamodels.datamodelBase import PowerOnModel
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Enums
|
# Enums
|
||||||
|
|
@ -72,7 +74,7 @@ class TeamsbotTransferMode(str, Enum):
|
||||||
# Database Models (stored in PostgreSQL)
|
# Database Models (stored in PostgreSQL)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
class TeamsbotSession(BaseModel):
|
class TeamsbotSession(PowerOnModel):
|
||||||
"""A Teams Bot meeting session."""
|
"""A Teams Bot meeting session."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Session ID")
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Session ID")
|
||||||
instanceId: str = Field(description="Feature instance ID (FK)")
|
instanceId: str = Field(description="Feature instance ID (FK)")
|
||||||
|
|
@ -90,11 +92,9 @@ class TeamsbotSession(BaseModel):
|
||||||
errorMessage: Optional[str] = Field(default=None, description="Error message if status is ERROR")
|
errorMessage: Optional[str] = Field(default=None, description="Error message if status is ERROR")
|
||||||
transcriptSegmentCount: int = Field(default=0, description="Number of transcript segments in this session")
|
transcriptSegmentCount: int = Field(default=0, description="Number of transcript segments in this session")
|
||||||
botResponseCount: int = Field(default=0, description="Number of bot responses in this session")
|
botResponseCount: int = Field(default=0, description="Number of bot responses in this session")
|
||||||
creationDate: Optional[str] = Field(default=None, description="ISO timestamp of record creation")
|
|
||||||
lastModified: Optional[str] = Field(default=None, description="ISO timestamp of last modification")
|
|
||||||
|
|
||||||
|
|
||||||
class TeamsbotTranscript(BaseModel):
|
class TeamsbotTranscript(PowerOnModel):
|
||||||
"""A single transcript segment from the meeting."""
|
"""A single transcript segment from the meeting."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Transcript segment ID")
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Transcript segment ID")
|
||||||
sessionId: str = Field(description="Session ID (FK)")
|
sessionId: str = Field(description="Session ID (FK)")
|
||||||
|
|
@ -105,10 +105,9 @@ class TeamsbotTranscript(BaseModel):
|
||||||
language: Optional[str] = Field(default=None, description="Detected language code (e.g., de-DE)")
|
language: Optional[str] = Field(default=None, description="Detected language code (e.g., de-DE)")
|
||||||
isFinal: bool = Field(default=True, description="Whether this is a final or interim result")
|
isFinal: bool = Field(default=True, description="Whether this is a final or interim result")
|
||||||
source: Optional[str] = Field(default=None, description="Source: caption, audioCapture, chat, chatHistory, speakerHint")
|
source: Optional[str] = Field(default=None, description="Source: caption, audioCapture, chat, chatHistory, speakerHint")
|
||||||
creationDate: Optional[str] = Field(default=None, description="ISO timestamp of record creation")
|
|
||||||
|
|
||||||
|
|
||||||
class TeamsbotBotResponse(BaseModel):
|
class TeamsbotBotResponse(PowerOnModel):
|
||||||
"""A bot response generated during a meeting session."""
|
"""A bot response generated during a meeting session."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Response ID")
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Response ID")
|
||||||
sessionId: str = Field(description="Session ID (FK)")
|
sessionId: str = Field(description="Session ID (FK)")
|
||||||
|
|
@ -121,14 +120,13 @@ class TeamsbotBotResponse(BaseModel):
|
||||||
processingTime: float = Field(default=0.0, description="Processing time in seconds")
|
processingTime: float = Field(default=0.0, description="Processing time in seconds")
|
||||||
priceCHF: float = Field(default=0.0, description="Cost of this AI call in CHF")
|
priceCHF: float = Field(default=0.0, description="Cost of this AI call in CHF")
|
||||||
timestamp: Optional[str] = Field(default=None, description="ISO timestamp of the response")
|
timestamp: Optional[str] = Field(default=None, description="ISO timestamp of the response")
|
||||||
creationDate: Optional[str] = Field(default=None, description="ISO timestamp of record creation")
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# System Bot Accounts (stored in PostgreSQL, credentials encrypted)
|
# System Bot Accounts (stored in PostgreSQL, credentials encrypted)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
class TeamsbotSystemBot(BaseModel):
|
class TeamsbotSystemBot(PowerOnModel):
|
||||||
"""A system bot account for authenticated meeting joins.
|
"""A system bot account for authenticated meeting joins.
|
||||||
Credentials are stored encrypted in the database, NOT in the UI-visible config.
|
Credentials are stored encrypted in the database, NOT in the UI-visible config.
|
||||||
Only mandate admins can manage system bots."""
|
Only mandate admins can manage system bots."""
|
||||||
|
|
@ -138,15 +136,13 @@ class TeamsbotSystemBot(BaseModel):
|
||||||
email: str = Field(description="Microsoft account email")
|
email: str = Field(description="Microsoft account email")
|
||||||
encryptedPassword: str = Field(description="Encrypted Microsoft account password")
|
encryptedPassword: str = Field(description="Encrypted Microsoft account password")
|
||||||
isActive: bool = Field(default=True, description="Whether this bot account is active")
|
isActive: bool = Field(default=True, description="Whether this bot account is active")
|
||||||
creationDate: Optional[str] = Field(default=None, description="ISO timestamp of creation")
|
|
||||||
lastModified: Optional[str] = Field(default=None, description="ISO timestamp of last modification")
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# User Account Credentials (stored in PostgreSQL, credentials encrypted)
|
# User Account Credentials (stored in PostgreSQL, credentials encrypted)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
class TeamsbotUserAccount(BaseModel):
|
class TeamsbotUserAccount(PowerOnModel):
|
||||||
"""Saved Microsoft credentials for 'Mein Account' joins.
|
"""Saved Microsoft credentials for 'Mein Account' joins.
|
||||||
Each user can store their own MS credentials per mandate.
|
Each user can store their own MS credentials per mandate.
|
||||||
Password is encrypted; on login only MFA confirmation is needed."""
|
Password is encrypted; on login only MFA confirmation is needed."""
|
||||||
|
|
@ -156,15 +152,13 @@ class TeamsbotUserAccount(BaseModel):
|
||||||
email: str = Field(description="Microsoft account email")
|
email: str = Field(description="Microsoft account email")
|
||||||
encryptedPassword: str = Field(description="Encrypted Microsoft account password")
|
encryptedPassword: str = Field(description="Encrypted Microsoft account password")
|
||||||
displayName: Optional[str] = Field(default=None, description="Display name derived from MS account")
|
displayName: Optional[str] = Field(default=None, description="Display name derived from MS account")
|
||||||
creationDate: Optional[str] = Field(default=None, description="ISO timestamp of creation")
|
|
||||||
lastModified: Optional[str] = Field(default=None, description="ISO timestamp of last modification")
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Per-User Settings (stored in PostgreSQL, per user per instance)
|
# Per-User Settings (stored in PostgreSQL, per user per instance)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
class TeamsbotUserSettings(BaseModel):
|
class TeamsbotUserSettings(PowerOnModel):
|
||||||
"""Per-user settings for the Teams Bot feature.
|
"""Per-user settings for the Teams Bot feature.
|
||||||
Each user has their own settings per feature instance.
|
Each user has their own settings per feature instance.
|
||||||
These override the instance-level defaults (TeamsbotConfig)."""
|
These override the instance-level defaults (TeamsbotConfig)."""
|
||||||
|
|
@ -182,8 +176,6 @@ class TeamsbotUserSettings(BaseModel):
|
||||||
triggerCooldownSeconds: Optional[int] = Field(default=None, description="Trigger cooldown override")
|
triggerCooldownSeconds: Optional[int] = Field(default=None, description="Trigger cooldown override")
|
||||||
contextWindowSegments: Optional[int] = Field(default=None, description="Context window override")
|
contextWindowSegments: Optional[int] = Field(default=None, description="Context window override")
|
||||||
debugMode: Optional[bool] = Field(default=None, description="Debug mode override")
|
debugMode: Optional[bool] = Field(default=None, description="Debug mode override")
|
||||||
creationDate: Optional[str] = Field(default=None, description="ISO timestamp of creation")
|
|
||||||
lastModified: Optional[str] = Field(default=None, description="ISO timestamp of last modification")
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ from typing import Dict, Any, List, Optional
|
||||||
|
|
||||||
from modules.datamodels.datamodelUam import User
|
from modules.datamodels.datamodelUam import User
|
||||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||||
from modules.shared.timeUtils import getIsoTimestamp
|
|
||||||
from modules.shared.configuration import APP_CONFIG
|
from modules.shared.configuration import APP_CONFIG
|
||||||
|
|
||||||
from .datamodelTeamsbot import (
|
from .datamodelTeamsbot import (
|
||||||
|
|
@ -104,13 +103,10 @@ class TeamsbotObjects:
|
||||||
|
|
||||||
def createSession(self, sessionData: Dict[str, Any]) -> Dict[str, Any]:
|
def createSession(self, sessionData: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Create a new session."""
|
"""Create a new session."""
|
||||||
sessionData["creationDate"] = getIsoTimestamp()
|
|
||||||
sessionData["lastModified"] = getIsoTimestamp()
|
|
||||||
return self.db.recordCreate(TeamsbotSession, sessionData)
|
return self.db.recordCreate(TeamsbotSession, sessionData)
|
||||||
|
|
||||||
def updateSession(self, sessionId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
def updateSession(self, sessionId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
"""Update session fields."""
|
"""Update session fields."""
|
||||||
updates["lastModified"] = getIsoTimestamp()
|
|
||||||
return self.db.recordModify(TeamsbotSession, sessionId, updates)
|
return self.db.recordModify(TeamsbotSession, sessionId, updates)
|
||||||
|
|
||||||
def deleteSession(self, sessionId: str) -> bool:
|
def deleteSession(self, sessionId: str) -> bool:
|
||||||
|
|
@ -149,7 +145,6 @@ class TeamsbotObjects:
|
||||||
|
|
||||||
def createTranscript(self, transcriptData: Dict[str, Any]) -> Dict[str, Any]:
|
def createTranscript(self, transcriptData: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Create a new transcript segment."""
|
"""Create a new transcript segment."""
|
||||||
transcriptData["creationDate"] = getIsoTimestamp()
|
|
||||||
return self.db.recordCreate(TeamsbotTranscript, transcriptData)
|
return self.db.recordCreate(TeamsbotTranscript, transcriptData)
|
||||||
|
|
||||||
def updateTranscript(self, transcriptId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
def updateTranscript(self, transcriptId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
|
|
@ -180,7 +175,6 @@ class TeamsbotObjects:
|
||||||
|
|
||||||
def createBotResponse(self, responseData: Dict[str, Any]) -> Dict[str, Any]:
|
def createBotResponse(self, responseData: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Create a new bot response record."""
|
"""Create a new bot response record."""
|
||||||
responseData["creationDate"] = getIsoTimestamp()
|
|
||||||
return self.db.recordCreate(TeamsbotBotResponse, responseData)
|
return self.db.recordCreate(TeamsbotBotResponse, responseData)
|
||||||
|
|
||||||
def _deleteResponsesBySession(self, sessionId: str) -> int:
|
def _deleteResponsesBySession(self, sessionId: str) -> int:
|
||||||
|
|
@ -216,13 +210,10 @@ class TeamsbotObjects:
|
||||||
|
|
||||||
def createSystemBot(self, botData: Dict[str, Any]) -> Dict[str, Any]:
|
def createSystemBot(self, botData: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Create a new system bot account."""
|
"""Create a new system bot account."""
|
||||||
botData["creationDate"] = getIsoTimestamp()
|
|
||||||
botData["lastModified"] = getIsoTimestamp()
|
|
||||||
return self.db.recordCreate(TeamsbotSystemBot, botData)
|
return self.db.recordCreate(TeamsbotSystemBot, botData)
|
||||||
|
|
||||||
def updateSystemBot(self, botId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
def updateSystemBot(self, botId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
"""Update a system bot account."""
|
"""Update a system bot account."""
|
||||||
updates["lastModified"] = getIsoTimestamp()
|
|
||||||
return self.db.recordModify(TeamsbotSystemBot, botId, updates)
|
return self.db.recordModify(TeamsbotSystemBot, botId, updates)
|
||||||
|
|
||||||
def deleteSystemBot(self, botId: str) -> bool:
|
def deleteSystemBot(self, botId: str) -> bool:
|
||||||
|
|
@ -243,13 +234,10 @@ class TeamsbotObjects:
|
||||||
|
|
||||||
def createUserSettings(self, settingsData: Dict[str, Any]) -> Dict[str, Any]:
|
def createUserSettings(self, settingsData: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Create user settings."""
|
"""Create user settings."""
|
||||||
settingsData["creationDate"] = getIsoTimestamp()
|
|
||||||
settingsData["lastModified"] = getIsoTimestamp()
|
|
||||||
return self.db.recordCreate(TeamsbotUserSettings, settingsData)
|
return self.db.recordCreate(TeamsbotUserSettings, settingsData)
|
||||||
|
|
||||||
def updateUserSettings(self, settingsId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
def updateUserSettings(self, settingsId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
"""Update user settings."""
|
"""Update user settings."""
|
||||||
updates["lastModified"] = getIsoTimestamp()
|
|
||||||
return self.db.recordModify(TeamsbotUserSettings, settingsId, updates)
|
return self.db.recordModify(TeamsbotUserSettings, settingsId, updates)
|
||||||
|
|
||||||
def deleteUserSettings(self, settingsId: str) -> bool:
|
def deleteUserSettings(self, settingsId: str) -> bool:
|
||||||
|
|
@ -270,13 +258,10 @@ class TeamsbotObjects:
|
||||||
|
|
||||||
def createUserAccount(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
def createUserAccount(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Create saved MS credentials."""
|
"""Create saved MS credentials."""
|
||||||
data["creationDate"] = getIsoTimestamp()
|
|
||||||
data["lastModified"] = getIsoTimestamp()
|
|
||||||
return self.db.recordCreate(TeamsbotUserAccount, data)
|
return self.db.recordCreate(TeamsbotUserAccount, data)
|
||||||
|
|
||||||
def updateUserAccount(self, accountId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
def updateUserAccount(self, accountId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
"""Update saved MS credentials."""
|
"""Update saved MS credentials."""
|
||||||
updates["lastModified"] = getIsoTimestamp()
|
|
||||||
return self.db.recordModify(TeamsbotUserAccount, accountId, updates)
|
return self.db.recordModify(TeamsbotUserAccount, accountId, updates)
|
||||||
|
|
||||||
def deleteUserAccount(self, accountId: str) -> bool:
|
def deleteUserAccount(self, accountId: str) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -103,25 +103,35 @@ TEMPLATE_ROLES = [
|
||||||
{"context": "RESOURCE", "item": "resource.feature.teamsbot.config.edit", "view": True},
|
{"context": "RESOURCE", "item": "resource.feature.teamsbot.config.edit", "view": True},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"roleLabel": "teamsbot-viewer",
|
||||||
|
"description": {
|
||||||
|
"en": "Teams Bot Viewer - View sessions and transcripts (read-only)",
|
||||||
|
"de": "Teams Bot Betrachter - Sitzungen und Transkripte ansehen (nur lesen)",
|
||||||
|
"fr": "Visualiseur Teams Bot - Consulter les sessions et transcriptions (lecture seule)",
|
||||||
|
},
|
||||||
|
"accessRules": [
|
||||||
|
{"context": "UI", "item": "ui.feature.teamsbot.dashboard", "view": True},
|
||||||
|
{"context": "UI", "item": "ui.feature.teamsbot.sessions", "view": True},
|
||||||
|
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"roleLabel": "teamsbot-user",
|
"roleLabel": "teamsbot-user",
|
||||||
"description": {
|
"description": {
|
||||||
"en": "Teams Bot User - Can start/stop sessions and view transcripts",
|
"en": "Teams Bot User - Can start/stop sessions and view transcripts",
|
||||||
"de": "Teams Bot Benutzer - Kann Sitzungen starten/stoppen und Transkripte einsehen",
|
"de": "Teams Bot Benutzer - Kann Sitzungen starten/stoppen und Transkripte einsehen",
|
||||||
"fr": "Utilisateur Teams Bot - Peut démarrer/arrêter des sessions et voir les transcriptions"
|
"fr": "Utilisateur Teams Bot - Peut démarrer/arrêter des sessions et voir les transcriptions",
|
||||||
},
|
},
|
||||||
"accessRules": [
|
"accessRules": [
|
||||||
# UI access to dashboard and sessions (not settings)
|
|
||||||
{"context": "UI", "item": "ui.feature.teamsbot.dashboard", "view": True},
|
{"context": "UI", "item": "ui.feature.teamsbot.dashboard", "view": True},
|
||||||
{"context": "UI", "item": "ui.feature.teamsbot.sessions", "view": True},
|
{"context": "UI", "item": "ui.feature.teamsbot.sessions", "view": True},
|
||||||
# Own records only
|
|
||||||
{"context": "DATA", "item": "data.feature.teamsbot.TeamsbotSession", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
|
{"context": "DATA", "item": "data.feature.teamsbot.TeamsbotSession", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
|
||||||
{"context": "DATA", "item": "data.feature.teamsbot.TeamsbotTranscript", "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
|
{"context": "DATA", "item": "data.feature.teamsbot.TeamsbotTranscript", "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
|
||||||
{"context": "DATA", "item": "data.feature.teamsbot.TeamsbotBotResponse", "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
|
{"context": "DATA", "item": "data.feature.teamsbot.TeamsbotBotResponse", "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
|
||||||
# Start and stop sessions
|
|
||||||
{"context": "RESOURCE", "item": "resource.feature.teamsbot.session.start", "view": True},
|
{"context": "RESOURCE", "item": "resource.feature.teamsbot.session.start", "view": True},
|
||||||
{"context": "RESOURCE", "item": "resource.feature.teamsbot.session.stop", "view": True},
|
{"context": "RESOURCE", "item": "resource.feature.teamsbot.session.stop", "view": True},
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -132,7 +142,7 @@ def getFeatureDefinition() -> Dict[str, Any]:
|
||||||
"code": FEATURE_CODE,
|
"code": FEATURE_CODE,
|
||||||
"label": FEATURE_LABEL,
|
"label": FEATURE_LABEL,
|
||||||
"icon": FEATURE_ICON,
|
"icon": FEATURE_ICON,
|
||||||
"autoCreateInstance": True,
|
"autoCreateInstance": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,13 @@
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from modules.datamodels.datamodelBase import PowerOnModel
|
||||||
from modules.shared.attributeUtils import registerModelLabels
|
from modules.shared.attributeUtils import registerModelLabels
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
class TrusteeOrganisation(BaseModel):
|
class TrusteeOrganisation(PowerOnModel):
|
||||||
"""Represents trustee organisations (companies) within the Trustee feature."""
|
"""Represents trustee organisations (companies) within the Trustee feature."""
|
||||||
id: str = Field( # Unique string label (PK), not UUID
|
id: str = Field( # Unique string label (PK), not UUID
|
||||||
description="Unique organisation identifier (label)",
|
description="Unique organisation identifier (label)",
|
||||||
|
|
@ -55,7 +57,7 @@ class TrusteeOrganisation(BaseModel):
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
# System attributes are automatically set by DatabaseConnector:
|
# System attributes are automatically set by DatabaseConnector:
|
||||||
# _createdAt, _modifiedAt, _createdBy, _modifiedBy
|
# sysCreatedAt, sysModifiedAt, sysCreatedBy, sysModifiedBy (PowerOnModel)
|
||||||
|
|
||||||
|
|
||||||
registerModelLabels(
|
registerModelLabels(
|
||||||
|
|
@ -71,7 +73,7 @@ registerModelLabels(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TrusteeRole(BaseModel):
|
class TrusteeRole(PowerOnModel):
|
||||||
"""Defines roles within the Trustee feature."""
|
"""Defines roles within the Trustee feature."""
|
||||||
id: str = Field( # Unique string label (PK), not UUID
|
id: str = Field( # Unique string label (PK), not UUID
|
||||||
description="Unique role identifier (label)",
|
description="Unique role identifier (label)",
|
||||||
|
|
@ -122,7 +124,7 @@ registerModelLabels(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TrusteeAccess(BaseModel):
|
class TrusteeAccess(PowerOnModel):
|
||||||
"""Defines user access to organisations with specific roles."""
|
"""Defines user access to organisations with specific roles."""
|
||||||
id: str = Field(
|
id: str = Field(
|
||||||
default_factory=lambda: str(uuid.uuid4()),
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
|
|
@ -207,7 +209,7 @@ registerModelLabels(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TrusteeContract(BaseModel):
|
class TrusteeContract(PowerOnModel):
|
||||||
"""Defines customer contracts within organisations."""
|
"""Defines customer contracts within organisations."""
|
||||||
id: str = Field(
|
id: str = Field(
|
||||||
default_factory=lambda: str(uuid.uuid4()),
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
|
|
@ -289,7 +291,7 @@ class TrusteeDocumentTypeEnum(str, Enum):
|
||||||
AUTO = "auto"
|
AUTO = "auto"
|
||||||
|
|
||||||
|
|
||||||
class TrusteeDocument(BaseModel):
|
class TrusteeDocument(PowerOnModel):
|
||||||
"""Contains document references for bookings.
|
"""Contains document references for bookings.
|
||||||
|
|
||||||
Documents reference files in the central Files table via fileId.
|
Documents reference files in the central Files table via fileId.
|
||||||
|
|
@ -413,7 +415,7 @@ registerModelLabels(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TrusteePosition(BaseModel):
|
class TrusteePosition(PowerOnModel):
|
||||||
"""Contains booking positions (expense entries).
|
"""Contains booking positions (expense entries).
|
||||||
|
|
||||||
A position can have up to two document references: documentId (Beleg) and bankDocumentId (Bank-Referenz).
|
A position can have up to two document references: documentId (Beleg) and bankDocumentId (Bank-Referenz).
|
||||||
|
|
@ -696,10 +698,6 @@ class TrusteePosition(BaseModel):
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Allow extra fields like _createdAt from database
|
|
||||||
model_config = {"extra": "allow"}
|
|
||||||
|
|
||||||
|
|
||||||
registerModelLabels(
|
registerModelLabels(
|
||||||
"TrusteePosition",
|
"TrusteePosition",
|
||||||
{"en": "Position", "fr": "Position", "de": "Position"},
|
{"en": "Position", "fr": "Position", "de": "Position"},
|
||||||
|
|
@ -739,7 +737,7 @@ registerModelLabels(
|
||||||
# ── TrusteeData* tables (synced from external accounting apps for analysis) ──
|
# ── TrusteeData* tables (synced from external accounting apps for analysis) ──
|
||||||
|
|
||||||
|
|
||||||
class TrusteeDataAccount(BaseModel):
|
class TrusteeDataAccount(PowerOnModel):
|
||||||
"""Chart of accounts synced from external accounting system."""
|
"""Chart of accounts synced from external accounting system."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
accountNumber: str = Field(description="Account number (e.g. '1020')")
|
accountNumber: str = Field(description="Account number (e.g. '1020')")
|
||||||
|
|
@ -769,7 +767,7 @@ registerModelLabels(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TrusteeDataJournalEntry(BaseModel):
|
class TrusteeDataJournalEntry(PowerOnModel):
|
||||||
"""Journal entry header synced from external accounting system."""
|
"""Journal entry header synced from external accounting system."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
externalId: Optional[str] = Field(default=None, description="ID in the source system")
|
externalId: Optional[str] = Field(default=None, description="ID in the source system")
|
||||||
|
|
@ -799,7 +797,7 @@ registerModelLabels(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TrusteeDataJournalLine(BaseModel):
|
class TrusteeDataJournalLine(PowerOnModel):
|
||||||
"""Journal entry line (debit/credit) synced from external accounting system."""
|
"""Journal entry line (debit/credit) synced from external accounting system."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
journalEntryId: str = Field(description="FK → TrusteeDataJournalEntry.id")
|
journalEntryId: str = Field(description="FK → TrusteeDataJournalEntry.id")
|
||||||
|
|
@ -833,7 +831,7 @@ registerModelLabels(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TrusteeDataContact(BaseModel):
|
class TrusteeDataContact(PowerOnModel):
|
||||||
"""Customer or vendor synced from external accounting system."""
|
"""Customer or vendor synced from external accounting system."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
externalId: Optional[str] = Field(default=None, description="ID in the source system")
|
externalId: Optional[str] = Field(default=None, description="ID in the source system")
|
||||||
|
|
@ -873,7 +871,7 @@ registerModelLabels(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TrusteeDataAccountBalance(BaseModel):
|
class TrusteeDataAccountBalance(PowerOnModel):
|
||||||
"""Account balance per period, derived from journal lines or directly from accounting system."""
|
"""Account balance per period, derived from journal lines or directly from accounting system."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
accountNumber: str = Field(description="Account number")
|
accountNumber: str = Field(description="Account number")
|
||||||
|
|
@ -907,7 +905,7 @@ registerModelLabels(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TrusteeAccountingConfig(BaseModel):
|
class TrusteeAccountingConfig(PowerOnModel):
|
||||||
"""Per-instance accounting system configuration with encrypted credentials.
|
"""Per-instance accounting system configuration with encrypted credentials.
|
||||||
|
|
||||||
Each feature instance can connect to exactly one accounting system.
|
Each feature instance can connect to exactly one accounting system.
|
||||||
|
|
@ -946,7 +944,7 @@ registerModelLabels(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TrusteeAccountingSync(BaseModel):
|
class TrusteeAccountingSync(PowerOnModel):
|
||||||
"""Tracks which position was synced to which external system and when.
|
"""Tracks which position was synced to which external system and when.
|
||||||
|
|
||||||
Used for duplicate prevention, audit trail, and retry logic.
|
Used for duplicate prevention, audit trail, and retry logic.
|
||||||
|
|
|
||||||
|
|
@ -1152,7 +1152,7 @@ class TrusteeObjects:
|
||||||
logger.warning(f"Document {documentId} not found")
|
logger.warning(f"Document {documentId} not found")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
createdBy = existing.get("_createdBy")
|
createdBy = existing.get("sysCreatedBy")
|
||||||
|
|
||||||
# Check system RBAC permission (userreport can only edit their own records)
|
# Check system RBAC permission (userreport can only edit their own records)
|
||||||
if not self.checkCombinedPermission(TrusteeDocument, "update", recordCreatedBy=createdBy):
|
if not self.checkCombinedPermission(TrusteeDocument, "update", recordCreatedBy=createdBy):
|
||||||
|
|
@ -1178,7 +1178,7 @@ class TrusteeObjects:
|
||||||
logger.warning(f"Document {documentId} not found")
|
logger.warning(f"Document {documentId} not found")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
createdBy = existing.get("_createdBy")
|
createdBy = existing.get("sysCreatedBy")
|
||||||
|
|
||||||
if not self.checkCombinedPermission(TrusteeDocument, "delete", recordCreatedBy=createdBy):
|
if not self.checkCombinedPermission(TrusteeDocument, "delete", recordCreatedBy=createdBy):
|
||||||
logger.warning(f"User {self.userId} lacks permission to delete document")
|
logger.warning(f"User {self.userId} lacks permission to delete document")
|
||||||
|
|
@ -1198,7 +1198,7 @@ class TrusteeObjects:
|
||||||
|
|
||||||
def _toTrusteePositionOrDelete(self, rawRecord: Dict[str, Any], deleteCorrupt: bool = True) -> Optional[TrusteePosition]:
|
def _toTrusteePositionOrDelete(self, rawRecord: Dict[str, Any], deleteCorrupt: bool = True) -> Optional[TrusteePosition]:
|
||||||
"""Build TrusteePosition safely; optionally delete irreparably corrupt records."""
|
"""Build TrusteePosition safely; optionally delete irreparably corrupt records."""
|
||||||
cleanRecord = {k: v for k, v in (rawRecord or {}).items() if not k.startswith("_") or k == "_createdAt"}
|
cleanRecord = {k: v for k, v in (rawRecord or {}).items() if not k.startswith("_") or k == "sysCreatedAt"}
|
||||||
if not cleanRecord:
|
if not cleanRecord:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -1271,7 +1271,7 @@ class TrusteeObjects:
|
||||||
"""Get all positions with RBAC filtering and optional DB-level pagination.
|
"""Get all positions with RBAC filtering and optional DB-level pagination.
|
||||||
|
|
||||||
Filtering, sorting, and pagination are handled at the SQL level.
|
Filtering, sorting, and pagination are handled at the SQL level.
|
||||||
Post-processing cleans internal fields (keeps _createdAt) and validates
|
Post-processing cleans internal fields (keeps sysCreatedAt) and validates
|
||||||
each record via _toTrusteePositionOrDelete (corrupt rows are deleted).
|
each record via _toTrusteePositionOrDelete (corrupt rows are deleted).
|
||||||
|
|
||||||
NOTE(post-process): totalItems may slightly overcount when corrupt legacy
|
NOTE(post-process): totalItems may slightly overcount when corrupt legacy
|
||||||
|
|
@ -1288,7 +1288,7 @@ class TrusteeObjects:
|
||||||
featureCode=self.FEATURE_CODE
|
featureCode=self.FEATURE_CODE
|
||||||
)
|
)
|
||||||
|
|
||||||
keepFields = {'_createdAt'}
|
keepFields = {'sysCreatedAt'}
|
||||||
|
|
||||||
def _cleanAndValidate(records):
|
def _cleanAndValidate(records):
|
||||||
items = []
|
items = []
|
||||||
|
|
@ -1369,7 +1369,7 @@ class TrusteeObjects:
|
||||||
logger.warning(f"Position {positionId} not found")
|
logger.warning(f"Position {positionId} not found")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
createdBy = existing.get("_createdBy")
|
createdBy = existing.get("sysCreatedBy")
|
||||||
|
|
||||||
# Check system RBAC permission (userreport can only edit their own records)
|
# Check system RBAC permission (userreport can only edit their own records)
|
||||||
if not self.checkCombinedPermission(TrusteePosition, "update", recordCreatedBy=createdBy):
|
if not self.checkCombinedPermission(TrusteePosition, "update", recordCreatedBy=createdBy):
|
||||||
|
|
@ -1391,7 +1391,7 @@ class TrusteeObjects:
|
||||||
logger.warning(f"Position {positionId} not found")
|
logger.warning(f"Position {positionId} not found")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
createdBy = existing.get("_createdBy")
|
createdBy = existing.get("sysCreatedBy")
|
||||||
|
|
||||||
if not self.checkCombinedPermission(TrusteePosition, "delete", recordCreatedBy=createdBy):
|
if not self.checkCombinedPermission(TrusteePosition, "delete", recordCreatedBy=createdBy):
|
||||||
logger.warning(f"User {self.userId} lacks permission to delete position")
|
logger.warning(f"User {self.userId} lacks permission to delete position")
|
||||||
|
|
|
||||||
|
|
@ -170,60 +170,81 @@ RESOURCE_OBJECTS = [
|
||||||
# Note: UI item=None means ALL views, specific items restrict to named views
|
# Note: UI item=None means ALL views, specific items restrict to named views
|
||||||
# IMPORTANT: item uses vollqualifizierte ObjectKeys (gemäss Navigation-API-Konzept)
|
# IMPORTANT: item uses vollqualifizierte ObjectKeys (gemäss Navigation-API-Konzept)
|
||||||
TEMPLATE_ROLES = [
|
TEMPLATE_ROLES = [
|
||||||
|
{
|
||||||
|
"roleLabel": "trustee-viewer",
|
||||||
|
"description": {
|
||||||
|
"en": "Trustee Viewer - View trustee data (read-only)",
|
||||||
|
"de": "Treuhand-Betrachter - Treuhand-Daten einsehen (nur lesen)",
|
||||||
|
"fr": "Visualiseur fiduciaire - Consulter les données fiduciaires (lecture seule)",
|
||||||
|
},
|
||||||
|
"accessRules": [
|
||||||
|
{"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
|
||||||
|
{"context": "UI", "item": "ui.feature.trustee.positions", "view": True},
|
||||||
|
{"context": "UI", "item": "ui.feature.trustee.documents", "view": True},
|
||||||
|
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roleLabel": "trustee-user",
|
||||||
|
"description": {
|
||||||
|
"en": "Trustee User - Create and manage own trustee records",
|
||||||
|
"de": "Treuhand-Benutzer - Eigene Treuhand-Daten erstellen und verwalten",
|
||||||
|
"fr": "Utilisateur fiduciaire - Créer et gérer ses propres données fiduciaires",
|
||||||
|
},
|
||||||
|
"accessRules": [
|
||||||
|
{"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
|
||||||
|
{"context": "UI", "item": "ui.feature.trustee.positions", "view": True},
|
||||||
|
{"context": "UI", "item": "ui.feature.trustee.documents", "view": True},
|
||||||
|
{"context": "UI", "item": "ui.feature.trustee.expense-import", "view": True},
|
||||||
|
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"roleLabel": "trustee-admin",
|
"roleLabel": "trustee-admin",
|
||||||
"description": {
|
"description": {
|
||||||
"en": "Trustee Administrator - Full access to all trustee data and settings",
|
"en": "Trustee Administrator - Full access to all trustee data and settings",
|
||||||
"de": "Treuhand-Administrator - Vollzugriff auf alle Treuhand-Daten und Einstellungen",
|
"de": "Treuhand-Administrator - Vollzugriff auf alle Treuhand-Daten und Einstellungen",
|
||||||
"fr": "Administrateur fiduciaire - Accès complet aux données et paramètres fiduciaires"
|
"fr": "Administrateur fiduciaire - Accès complet aux données et paramètres fiduciaires",
|
||||||
},
|
},
|
||||||
"accessRules": [
|
"accessRules": [
|
||||||
# Full UI access (all views including admin views)
|
|
||||||
{"context": "UI", "item": None, "view": True},
|
{"context": "UI", "item": None, "view": True},
|
||||||
# Full DATA access
|
|
||||||
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"},
|
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"},
|
||||||
# Admin resource: manage instance roles
|
|
||||||
{"context": "RESOURCE", "item": "resource.feature.trustee.instance-roles.manage", "view": True},
|
{"context": "RESOURCE", "item": "resource.feature.trustee.instance-roles.manage", "view": True},
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"roleLabel": "trustee-accountant",
|
"roleLabel": "trustee-accountant",
|
||||||
"description": {
|
"description": {
|
||||||
"en": "Trustee Accountant - Manage accounting and financial data",
|
"en": "Trustee Accountant - Manage accounting and financial data",
|
||||||
"de": "Treuhand-Buchhalter - Buchhaltungs- und Finanzdaten verwalten",
|
"de": "Treuhand-Buchhalter - Buchhaltungs- und Finanzdaten verwalten",
|
||||||
"fr": "Comptable fiduciaire - Gérer les données comptables et financières"
|
"fr": "Comptable fiduciaire - Gérer les données comptables et financières",
|
||||||
},
|
},
|
||||||
"accessRules": [
|
"accessRules": [
|
||||||
# UI access to main views (not admin views, not expense-import) - vollqualifizierte ObjectKeys
|
|
||||||
{"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
|
{"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
|
||||||
{"context": "UI", "item": "ui.feature.trustee.positions", "view": True},
|
{"context": "UI", "item": "ui.feature.trustee.positions", "view": True},
|
||||||
{"context": "UI", "item": "ui.feature.trustee.documents", "view": True},
|
{"context": "UI", "item": "ui.feature.trustee.documents", "view": True},
|
||||||
{"context": "UI", "item": "ui.feature.trustee.settings", "view": True},
|
{"context": "UI", "item": "ui.feature.trustee.settings", "view": True},
|
||||||
# Group-level DATA access
|
|
||||||
{"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "g"},
|
{"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "g"},
|
||||||
# Accounting sync permission
|
|
||||||
{"context": "RESOURCE", "item": "resource.feature.trustee.accounting.sync", "view": True},
|
{"context": "RESOURCE", "item": "resource.feature.trustee.accounting.sync", "view": True},
|
||||||
{"context": "RESOURCE", "item": "resource.feature.trustee.accounting.view", "view": True},
|
{"context": "RESOURCE", "item": "resource.feature.trustee.accounting.view", "view": True},
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"roleLabel": "trustee-client",
|
"roleLabel": "trustee-client",
|
||||||
"description": {
|
"description": {
|
||||||
"en": "Trustee Client - View own accounting data and documents",
|
"en": "Trustee Client - View own accounting data and documents",
|
||||||
"de": "Treuhand-Kunde - Eigene Buchhaltungsdaten und Dokumente einsehen",
|
"de": "Treuhand-Kunde - Eigene Buchhaltungsdaten und Dokumente einsehen",
|
||||||
"fr": "Client fiduciaire - Consulter ses propres données comptables et documents"
|
"fr": "Client fiduciaire - Consulter ses propres données comptables et documents",
|
||||||
},
|
},
|
||||||
"accessRules": [
|
"accessRules": [
|
||||||
# UI access to main views + expense-import - vollqualifizierte ObjectKeys
|
|
||||||
{"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
|
{"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
|
||||||
{"context": "UI", "item": "ui.feature.trustee.positions", "view": True},
|
{"context": "UI", "item": "ui.feature.trustee.positions", "view": True},
|
||||||
{"context": "UI", "item": "ui.feature.trustee.documents", "view": True},
|
{"context": "UI", "item": "ui.feature.trustee.documents", "view": True},
|
||||||
{"context": "UI", "item": "ui.feature.trustee.expense-import", "view": True},
|
{"context": "UI", "item": "ui.feature.trustee.expense-import", "view": True},
|
||||||
{"context": "UI", "item": "ui.feature.trustee.scan-upload", "view": True},
|
{"context": "UI", "item": "ui.feature.trustee.scan-upload", "view": True},
|
||||||
# Own records only (MY level)
|
|
||||||
{"context": "DATA", "item": "data.feature.trustee.TrusteePosition", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
|
{"context": "DATA", "item": "data.feature.trustee.TrusteePosition", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
|
||||||
{"context": "DATA", "item": "data.feature.trustee.TrusteeDocument", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
|
{"context": "DATA", "item": "data.feature.trustee.TrusteeDocument", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,15 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""Workspace feature data models — VoiceSettings and WorkspaceUserSettings."""
|
"""Workspace feature data models — WorkspaceUserSettings."""
|
||||||
|
|
||||||
from typing import Dict, Any, Optional
|
from typing import Optional
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from modules.datamodels.datamodelBase import PowerOnModel
|
||||||
from modules.shared.attributeUtils import registerModelLabels
|
from modules.shared.attributeUtils import registerModelLabels
|
||||||
from modules.shared.timeUtils import getUtcTimestamp
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
class VoiceSettings(BaseModel):
|
class WorkspaceUserSettings(PowerOnModel):
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
|
||||||
userId: str = Field(description="ID of the user these settings belong to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
|
||||||
mandateId: str = Field(description="ID of the mandate these settings belong to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
|
||||||
featureInstanceId: str = Field(description="ID of the feature instance these settings belong to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
|
||||||
sttLanguage: str = Field(default="de-DE", description="Speech-to-Text language", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True})
|
|
||||||
ttsLanguage: str = Field(default="de-DE", description="Text-to-Speech language", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True})
|
|
||||||
ttsVoice: str = Field(default="de-DE-KatjaNeural", description="Text-to-Speech voice", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True})
|
|
||||||
ttsVoiceMap: Dict[str, Any] = Field(default_factory=dict, description="Per-language voice mapping, e.g. {'de-DE': {'voiceName': 'de-DE-Wavenet-A'}, 'en-US': {'voiceName': 'en-US-Wavenet-C'}}", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False})
|
|
||||||
translationEnabled: bool = Field(default=True, description="Whether translation is enabled", json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False})
|
|
||||||
targetLanguage: str = Field(default="en-US", description="Target language for translation", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False})
|
|
||||||
creationDate: float = Field(default_factory=getUtcTimestamp, description="Date when the settings were created (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
|
|
||||||
lastModified: float = Field(default_factory=getUtcTimestamp, description="Date when the settings were last modified (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
|
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceUserSettings(BaseModel):
|
|
||||||
"""Per-user workspace settings. None values mean 'use instance default'."""
|
"""Per-user workspace settings. None values mean 'use instance default'."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||||
userId: str = Field(description="User ID", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
userId: str = Field(description="User ID", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
||||||
|
|
@ -33,25 +18,6 @@ class WorkspaceUserSettings(BaseModel):
|
||||||
maxAgentRounds: Optional[int] = Field(default=None, description="Max agent rounds override (None = instance default)", json_schema_extra={"frontend_type": "number", "frontend_readonly": False, "frontend_required": False})
|
maxAgentRounds: Optional[int] = Field(default=None, description="Max agent rounds override (None = instance default)", json_schema_extra={"frontend_type": "number", "frontend_readonly": False, "frontend_required": False})
|
||||||
|
|
||||||
|
|
||||||
registerModelLabels(
|
|
||||||
"VoiceSettings",
|
|
||||||
{"en": "Voice Settings", "fr": "Paramètres vocaux"},
|
|
||||||
{
|
|
||||||
"id": {"en": "ID", "fr": "ID"},
|
|
||||||
"userId": {"en": "User ID", "fr": "ID utilisateur"},
|
|
||||||
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
|
|
||||||
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
|
|
||||||
"sttLanguage": {"en": "STT Language", "fr": "Langue STT"},
|
|
||||||
"ttsLanguage": {"en": "TTS Language", "fr": "Langue TTS"},
|
|
||||||
"ttsVoice": {"en": "TTS Voice", "fr": "Voix TTS"},
|
|
||||||
"ttsVoiceMap": {"en": "TTS Voice Map", "fr": "Carte des voix TTS"},
|
|
||||||
"translationEnabled": {"en": "Translation Enabled", "fr": "Traduction activée"},
|
|
||||||
"targetLanguage": {"en": "Target Language", "fr": "Langue cible"},
|
|
||||||
"creationDate": {"en": "Creation Date", "fr": "Date de création"},
|
|
||||||
"lastModified": {"en": "Last Modified", "fr": "Dernière modification"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
registerModelLabels(
|
registerModelLabels(
|
||||||
"WorkspaceUserSettings",
|
"WorkspaceUserSettings",
|
||||||
{"en": "Workspace User Settings", "de": "Workspace Benutzereinstellungen"},
|
{"en": "Workspace User Settings", "de": "Workspace Benutzereinstellungen"},
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
Interface for Workspace feature — manages VoiceSettings and WorkspaceUserSettings.
|
Interface for Workspace feature — manages WorkspaceUserSettings.
|
||||||
Uses a dedicated poweron_workspace database.
|
Uses a dedicated poweron_workspace database.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -10,11 +10,10 @@ from typing import Dict, Any, Optional
|
||||||
|
|
||||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||||
from modules.datamodels.datamodelUam import User
|
from modules.datamodels.datamodelUam import User
|
||||||
from modules.features.workspace.datamodelFeatureWorkspace import VoiceSettings, WorkspaceUserSettings
|
from modules.features.workspace.datamodelFeatureWorkspace import WorkspaceUserSettings
|
||||||
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
|
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
|
||||||
from modules.security.rbac import RbacClass
|
from modules.security.rbac import RbacClass
|
||||||
from modules.shared.configuration import APP_CONFIG
|
from modules.shared.configuration import APP_CONFIG
|
||||||
from modules.shared.timeUtils import getUtcTimestamp
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -62,122 +61,6 @@ class WorkspaceObjects:
|
||||||
self.featureInstanceId = featureInstanceId
|
self.featureInstanceId = featureInstanceId
|
||||||
self.db.updateContext(self.userId)
|
self.db.updateContext(self.userId)
|
||||||
|
|
||||||
# =========================================================================
|
|
||||||
# VoiceSettings CRUD
|
|
||||||
# =========================================================================
|
|
||||||
|
|
||||||
def getVoiceSettings(self, userId: Optional[str] = None) -> Optional[VoiceSettings]:
|
|
||||||
try:
|
|
||||||
targetUserId = userId or self.userId
|
|
||||||
if not targetUserId:
|
|
||||||
logger.error("No user ID provided for voice settings")
|
|
||||||
return None
|
|
||||||
|
|
||||||
recordFilter: Dict[str, Any] = {"userId": targetUserId}
|
|
||||||
if self.featureInstanceId:
|
|
||||||
recordFilter["featureInstanceId"] = self.featureInstanceId
|
|
||||||
|
|
||||||
filteredSettings = getRecordsetWithRBAC(
|
|
||||||
self.db, VoiceSettings, self.currentUser,
|
|
||||||
recordFilter=recordFilter, mandateId=self.mandateId,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not filteredSettings:
|
|
||||||
return None
|
|
||||||
|
|
||||||
settingsData = filteredSettings[0]
|
|
||||||
if not settingsData.get("creationDate"):
|
|
||||||
settingsData["creationDate"] = getUtcTimestamp()
|
|
||||||
if not settingsData.get("lastModified"):
|
|
||||||
settingsData["lastModified"] = getUtcTimestamp()
|
|
||||||
|
|
||||||
return VoiceSettings(**settingsData)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting voice settings: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def createVoiceSettings(self, settingsData: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
try:
|
|
||||||
if "userId" not in settingsData:
|
|
||||||
settingsData["userId"] = self.userId
|
|
||||||
if "mandateId" not in settingsData:
|
|
||||||
settingsData["mandateId"] = self.mandateId
|
|
||||||
if "featureInstanceId" not in settingsData:
|
|
||||||
settingsData["featureInstanceId"] = self.featureInstanceId
|
|
||||||
|
|
||||||
existing = self.getVoiceSettings(settingsData["userId"])
|
|
||||||
if existing:
|
|
||||||
raise ValueError(f"Voice settings already exist for user {settingsData['userId']}")
|
|
||||||
|
|
||||||
createdRecord = self.db.recordCreate(VoiceSettings, settingsData)
|
|
||||||
if not createdRecord or not createdRecord.get("id"):
|
|
||||||
raise ValueError("Failed to create voice settings record")
|
|
||||||
|
|
||||||
logger.info(f"Created voice settings for user {settingsData['userId']}")
|
|
||||||
return createdRecord
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error creating voice settings: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
def updateVoiceSettings(self, userId: str, updateData: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
try:
|
|
||||||
existing = self.getVoiceSettings(userId)
|
|
||||||
if not existing:
|
|
||||||
raise ValueError(f"Voice settings not found for user {userId}")
|
|
||||||
|
|
||||||
updateData["lastModified"] = getUtcTimestamp()
|
|
||||||
success = self.db.recordModify(VoiceSettings, existing.id, updateData)
|
|
||||||
if not success:
|
|
||||||
raise ValueError("Failed to update voice settings record")
|
|
||||||
|
|
||||||
updated = self.getVoiceSettings(userId)
|
|
||||||
if not updated:
|
|
||||||
raise ValueError("Failed to retrieve updated voice settings")
|
|
||||||
|
|
||||||
logger.info(f"Updated voice settings for user {userId}")
|
|
||||||
return updated.model_dump()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error updating voice settings: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
def deleteVoiceSettings(self, userId: str) -> bool:
|
|
||||||
try:
|
|
||||||
existing = self.getVoiceSettings(userId)
|
|
||||||
if not existing:
|
|
||||||
return False
|
|
||||||
success = self.db.recordDelete(VoiceSettings, existing.id)
|
|
||||||
if success:
|
|
||||||
logger.info(f"Deleted voice settings for user {userId}")
|
|
||||||
return success
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error deleting voice settings: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def getOrCreateVoiceSettings(self, userId: Optional[str] = None) -> VoiceSettings:
|
|
||||||
targetUserId = userId or self.userId
|
|
||||||
if not targetUserId:
|
|
||||||
raise ValueError("No user ID provided for voice settings")
|
|
||||||
|
|
||||||
existing = self.getVoiceSettings(targetUserId)
|
|
||||||
if existing:
|
|
||||||
return existing
|
|
||||||
|
|
||||||
defaultSettings = {
|
|
||||||
"userId": targetUserId,
|
|
||||||
"mandateId": self.mandateId,
|
|
||||||
"featureInstanceId": self.featureInstanceId,
|
|
||||||
"sttLanguage": "de-DE",
|
|
||||||
"ttsLanguage": "de-DE",
|
|
||||||
"ttsVoice": "de-DE-KatjaNeural",
|
|
||||||
"translationEnabled": True,
|
|
||||||
"targetLanguage": "en-US",
|
|
||||||
}
|
|
||||||
createdRecord = self.createVoiceSettings(defaultSettings)
|
|
||||||
return VoiceSettings(**createdRecord)
|
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# WorkspaceUserSettings CRUD
|
# WorkspaceUserSettings CRUD
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,7 @@ TEMPLATE_ROLES = [
|
||||||
"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},
|
||||||
# DATA: never ALL in shared instances — every role (including admin) sees only _createdBy = self
|
# DATA: never ALL in shared instances — every role (including admin) sees only sysCreatedBy = self
|
||||||
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
|
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,27 @@ class _PendingEditsStore:
|
||||||
_pendingEditsStore = _PendingEditsStore()
|
_pendingEditsStore = _PendingEditsStore()
|
||||||
|
|
||||||
|
|
||||||
|
def _workspaceBillingFeatureCode(user, mandateId: Optional[str], instanceId: str) -> Optional[str]:
|
||||||
|
"""Resolve FeatureInstance.featureCode for billing/UI when workflow is not on ServiceCenterContext."""
|
||||||
|
if not instanceId or not str(instanceId).strip():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
|
||||||
|
|
||||||
|
appIf = getAppInterface(user, mandateId=mandateId or None)
|
||||||
|
inst = appIf.getFeatureInstance(str(instanceId).strip())
|
||||||
|
if not inst:
|
||||||
|
return None
|
||||||
|
if isinstance(inst, dict):
|
||||||
|
code = inst.get("featureCode")
|
||||||
|
else:
|
||||||
|
code = getattr(inst, "featureCode", None)
|
||||||
|
return str(code).strip() if code else None
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Workspace: feature code lookup failed for instance %s: %s", instanceId, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceInputRequest(BaseModel):
|
class WorkspaceInputRequest(BaseModel):
|
||||||
"""Prompt input for the unified workspace."""
|
"""Prompt input for the unified workspace."""
|
||||||
prompt: str = Field(description="User prompt text")
|
prompt: str = Field(description="User prompt text")
|
||||||
|
|
@ -87,6 +108,7 @@ class WorkspaceInputRequest(BaseModel):
|
||||||
workflowId: Optional[str] = Field(default=None, description="Continue existing workflow")
|
workflowId: Optional[str] = Field(default=None, description="Continue existing workflow")
|
||||||
userLanguage: str = Field(default="en", description="User language code")
|
userLanguage: str = Field(default="en", description="User language code")
|
||||||
allowedProviders: List[str] = Field(default_factory=list, description="Restrict AI to these providers")
|
allowedProviders: List[str] = Field(default_factory=list, description="Restrict AI to these providers")
|
||||||
|
requireNeutralization: Optional[bool] = Field(default=None, description="Per-request neutralization override")
|
||||||
|
|
||||||
|
|
||||||
async def _getAiObjects() -> AiObjects:
|
async def _getAiObjects() -> AiObjects:
|
||||||
|
|
@ -546,11 +568,13 @@ async def streamWorkspaceStart(
|
||||||
from modules.serviceCenter import getService
|
from modules.serviceCenter import getService
|
||||||
from modules.serviceCenter.context import ServiceCenterContext
|
from modules.serviceCenter.context import ServiceCenterContext
|
||||||
|
|
||||||
|
wsBillingFeatureCode = _workspaceBillingFeatureCode(context.user, mandateId or "", instanceId)
|
||||||
svcCtx = ServiceCenterContext(
|
svcCtx = ServiceCenterContext(
|
||||||
user=context.user,
|
user=context.user,
|
||||||
mandate_id=mandateId or "",
|
mandate_id=mandateId or "",
|
||||||
feature_instance_id=instanceId,
|
feature_instance_id=instanceId,
|
||||||
workflow_id=workflowId,
|
workflow_id=workflowId,
|
||||||
|
feature_code=wsBillingFeatureCode,
|
||||||
)
|
)
|
||||||
chatSvc = getService("chat", svcCtx)
|
chatSvc = getService("chat", svcCtx)
|
||||||
attachmentLabel = _buildWorkspaceAttachmentLabel(
|
attachmentLabel = _buildWorkspaceAttachmentLabel(
|
||||||
|
|
@ -589,6 +613,8 @@ async def streamWorkspaceStart(
|
||||||
userLanguage=userInput.userLanguage,
|
userLanguage=userInput.userLanguage,
|
||||||
instanceConfig=instanceConfig,
|
instanceConfig=instanceConfig,
|
||||||
allowedProviders=userInput.allowedProviders,
|
allowedProviders=userInput.allowedProviders,
|
||||||
|
requireNeutralization=userInput.requireNeutralization,
|
||||||
|
billingFeatureCode=wsBillingFeatureCode,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
eventManager.register_agent_task(queueId, agentTask)
|
eventManager.register_agent_task(queueId, agentTask)
|
||||||
|
|
@ -644,6 +670,8 @@ async def _runWorkspaceAgent(
|
||||||
userLanguage: str = "en",
|
userLanguage: str = "en",
|
||||||
instanceConfig: Dict[str, Any] = None,
|
instanceConfig: Dict[str, Any] = None,
|
||||||
allowedProviders: List[str] = None,
|
allowedProviders: List[str] = None,
|
||||||
|
requireNeutralization: Optional[bool] = None,
|
||||||
|
billingFeatureCode: Optional[str] = None,
|
||||||
):
|
):
|
||||||
"""Run the serviceAgent loop and forward events to the SSE queue."""
|
"""Run the serviceAgent loop and forward events to the SSE queue."""
|
||||||
try:
|
try:
|
||||||
|
|
@ -654,6 +682,7 @@ async def _runWorkspaceAgent(
|
||||||
mandate_id=mandateId,
|
mandate_id=mandateId,
|
||||||
feature_instance_id=instanceId,
|
feature_instance_id=instanceId,
|
||||||
workflow_id=workflowId,
|
workflow_id=workflowId,
|
||||||
|
feature_code=billingFeatureCode,
|
||||||
)
|
)
|
||||||
agentService = getService("agent", ctx)
|
agentService = getService("agent", ctx)
|
||||||
chatService = getService("chat", ctx)
|
chatService = getService("chat", ctx)
|
||||||
|
|
@ -661,6 +690,11 @@ async def _runWorkspaceAgent(
|
||||||
|
|
||||||
if allowedProviders:
|
if allowedProviders:
|
||||||
aiService.services.allowedProviders = allowedProviders
|
aiService.services.allowedProviders = allowedProviders
|
||||||
|
logger.info(f"Workspace agent: allowedProviders={allowedProviders}")
|
||||||
|
else:
|
||||||
|
logger.debug("Workspace agent: no allowedProviders in request")
|
||||||
|
if requireNeutralization is not None:
|
||||||
|
ctx.requireNeutralization = requireNeutralization
|
||||||
|
|
||||||
wfRecord = chatInterface.getWorkflow(workflowId) if workflowId else None
|
wfRecord = chatInterface.getWorkflow(workflowId) if workflowId else None
|
||||||
wfName = ""
|
wfName = ""
|
||||||
|
|
@ -888,12 +922,30 @@ async def listWorkspaceWorkflows(
|
||||||
request: Request,
|
request: Request,
|
||||||
instanceId: str = Path(...),
|
instanceId: str = Path(...),
|
||||||
includeArchived: bool = Query(default=False, description="Include archived workflows"),
|
includeArchived: bool = Query(default=False, description="Include archived workflows"),
|
||||||
|
search: str = Query(default="", description="Fulltext search in workflow titles and message content"),
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
"""List workspace workflows/conversations for this instance."""
|
"""List workspace workflows/conversations for this instance."""
|
||||||
_validateInstanceAccess(instanceId, context)
|
_validateInstanceAccess(instanceId, context)
|
||||||
chatInterface = _getChatInterface(context, featureInstanceId=instanceId)
|
chatInterface = _getChatInterface(context, featureInstanceId=instanceId)
|
||||||
workflows = chatInterface.getWorkflows() or []
|
workflows = chatInterface.getWorkflows() or []
|
||||||
|
|
||||||
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
|
rootIf = getRootInterface()
|
||||||
|
_fiCache: Dict[str, Dict[str, str]] = {}
|
||||||
|
|
||||||
|
def _resolveFeatureLabels(fiId: str) -> Dict[str, str]:
|
||||||
|
if fiId not in _fiCache:
|
||||||
|
fi = rootIf.getFeatureInstance(fiId)
|
||||||
|
if fi:
|
||||||
|
_fiCache[fiId] = {
|
||||||
|
"featureLabel": getattr(fi, "label", "") or getattr(fi, "featureCode", fiId),
|
||||||
|
"featureCode": getattr(fi, "featureCode", ""),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
_fiCache[fiId] = {"featureLabel": fiId[:8], "featureCode": ""}
|
||||||
|
return _fiCache[fiId]
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
for wf in workflows:
|
for wf in workflows:
|
||||||
if isinstance(wf, dict):
|
if isinstance(wf, dict):
|
||||||
|
|
@ -905,13 +957,63 @@ async def listWorkspaceWorkflows(
|
||||||
"status": getattr(wf, "status", ""),
|
"status": getattr(wf, "status", ""),
|
||||||
"startedAt": getattr(wf, "startedAt", None),
|
"startedAt": getattr(wf, "startedAt", None),
|
||||||
"lastActivity": getattr(wf, "lastActivity", None),
|
"lastActivity": getattr(wf, "lastActivity", None),
|
||||||
|
"featureInstanceId": getattr(wf, "featureInstanceId", instanceId),
|
||||||
}
|
}
|
||||||
if not includeArchived and item.get("status") == "archived":
|
if not includeArchived and item.get("status") == "archived":
|
||||||
continue
|
continue
|
||||||
|
fiId = item.get("featureInstanceId") or instanceId
|
||||||
|
labels = _resolveFeatureLabels(fiId)
|
||||||
|
item.setdefault("featureLabel", labels["featureLabel"])
|
||||||
|
item.setdefault("featureCode", labels["featureCode"])
|
||||||
|
item.setdefault("featureInstanceId", fiId)
|
||||||
|
|
||||||
|
lastMsg = chatInterface.getLastMessageTimestamp(item.get("id"))
|
||||||
|
if lastMsg:
|
||||||
|
item["lastMessageAt"] = lastMsg
|
||||||
|
|
||||||
items.append(item)
|
items.append(item)
|
||||||
|
|
||||||
|
if search and search.strip():
|
||||||
|
searchLower = search.strip().lower()
|
||||||
|
matchedIds = set()
|
||||||
|
for item in items:
|
||||||
|
if searchLower in (item.get("name") or "").lower() or searchLower in (item.get("label") or "").lower():
|
||||||
|
matchedIds.add(item["id"])
|
||||||
|
contentHits = chatInterface.searchWorkflowsByContent(searchLower, limit=50)
|
||||||
|
matchedIds.update(contentHits)
|
||||||
|
items = [i for i in items if i["id"] in matchedIds]
|
||||||
|
|
||||||
return JSONResponse({"workflows": items})
|
return JSONResponse({"workflows": items})
|
||||||
|
|
||||||
|
|
||||||
|
class ResolveRagRequest(BaseModel):
|
||||||
|
"""Request body for resolving a chat via RAG."""
|
||||||
|
chatId: str = Field(..., description="Workflow/chat ID to resolve")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{instanceId}/resolve-rag")
|
||||||
|
@limiter.limit("60/minute")
|
||||||
|
async def resolveRag(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str = Path(...),
|
||||||
|
body: ResolveRagRequest = Body(...),
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
):
|
||||||
|
"""Build a RAG summary for a chat (workflow) to inject into the input area."""
|
||||||
|
_validateInstanceAccess(instanceId, context)
|
||||||
|
chatInterface = _getChatInterface(context, featureInstanceId=instanceId)
|
||||||
|
messages = chatInterface.getMessages(body.chatId) or []
|
||||||
|
|
||||||
|
texts = []
|
||||||
|
for msg in messages[:30]:
|
||||||
|
content = msg.get("message") if isinstance(msg, dict) else getattr(msg, "message", "")
|
||||||
|
if content:
|
||||||
|
texts.append(content[:500])
|
||||||
|
|
||||||
|
summary = "\n---\n".join(texts[:10]) if texts else ""
|
||||||
|
return JSONResponse({"summary": summary, "chatId": body.chatId, "messageCount": len(texts)})
|
||||||
|
|
||||||
|
|
||||||
class UpdateWorkflowRequest(BaseModel):
|
class UpdateWorkflowRequest(BaseModel):
|
||||||
"""Request body for updating a workflow (PATCH)."""
|
"""Request body for updating a workflow (PATCH)."""
|
||||||
name: Optional[str] = Field(default=None, description="New workflow name")
|
name: Optional[str] = Field(default=None, description="New workflow name")
|
||||||
|
|
@ -1583,137 +1685,6 @@ async def synthesizeVoice(
|
||||||
return JSONResponse({"audio": None, "note": "TTS via browser Speech Synthesis API recommended"})
|
return JSONResponse({"audio": None, "note": "TTS via browser Speech Synthesis API recommended"})
|
||||||
|
|
||||||
|
|
||||||
# =========================================================================
|
|
||||||
# Voice Settings Endpoints
|
|
||||||
# =========================================================================
|
|
||||||
|
|
||||||
@router.get("/{instanceId}/settings/voice")
|
|
||||||
@limiter.limit("120/minute")
|
|
||||||
async def getVoiceSettings(
|
|
||||||
request: Request,
|
|
||||||
instanceId: str = Path(...),
|
|
||||||
context: RequestContext = Depends(getRequestContext),
|
|
||||||
):
|
|
||||||
"""Load voice settings for the current user and instance."""
|
|
||||||
_validateInstanceAccess(instanceId, context)
|
|
||||||
wsInterface = _getWorkspaceInterface(context, instanceId)
|
|
||||||
userId = str(context.user.id)
|
|
||||||
try:
|
|
||||||
vs = wsInterface.getVoiceSettings(userId)
|
|
||||||
if not vs:
|
|
||||||
logger.info(f"GET voice settings: not found for user={userId}, creating defaults")
|
|
||||||
vs = wsInterface.getOrCreateVoiceSettings(userId)
|
|
||||||
result = vs.model_dump() if vs else {}
|
|
||||||
mapKeys = list(result.get("ttsVoiceMap", {}).keys()) if result else []
|
|
||||||
logger.info(f"GET voice settings for user={userId}: ttsVoiceMap languages={mapKeys}")
|
|
||||||
return JSONResponse(result)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to load voice settings for user={userId}: {e}", exc_info=True)
|
|
||||||
return JSONResponse({"ttsVoiceMap": {}}, status_code=200)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{instanceId}/settings/voice")
|
|
||||||
@limiter.limit("120/minute")
|
|
||||||
async def updateVoiceSettings(
|
|
||||||
request: Request,
|
|
||||||
instanceId: str = Path(...),
|
|
||||||
body: dict = Body(...),
|
|
||||||
context: RequestContext = Depends(getRequestContext),
|
|
||||||
):
|
|
||||||
"""Update voice settings for the current user and instance."""
|
|
||||||
_validateInstanceAccess(instanceId, context)
|
|
||||||
wsInterface = _getWorkspaceInterface(context, instanceId)
|
|
||||||
userId = str(context.user.id)
|
|
||||||
|
|
||||||
try:
|
|
||||||
logger.info(f"PUT voice settings for user={userId}, instance={instanceId}, body keys={list(body.keys())}")
|
|
||||||
vs = wsInterface.getVoiceSettings(userId)
|
|
||||||
if not vs:
|
|
||||||
logger.info(f"No existing voice settings, creating new for user={userId}")
|
|
||||||
createData = {
|
|
||||||
"userId": userId,
|
|
||||||
"mandateId": str(context.mandateId) if context.mandateId else "",
|
|
||||||
"featureInstanceId": instanceId,
|
|
||||||
}
|
|
||||||
createData.update(body)
|
|
||||||
created = wsInterface.createVoiceSettings(createData)
|
|
||||||
logger.info(f"Created voice settings for user={userId}, ttsVoiceMap keys={list((created or {}).get('ttsVoiceMap', {}).keys())}")
|
|
||||||
return JSONResponse(created)
|
|
||||||
|
|
||||||
updateData = {k: v for k, v in body.items() if k not in ("id", "userId", "mandateId", "featureInstanceId", "creationDate")}
|
|
||||||
logger.info(f"Updating voice settings for user={userId}, update keys={list(updateData.keys())}")
|
|
||||||
updated = wsInterface.updateVoiceSettings(userId, updateData)
|
|
||||||
logger.info(f"Updated voice settings for user={userId}, ttsVoiceMap keys={list((updated or {}).get('ttsVoiceMap', {}).keys())}")
|
|
||||||
return JSONResponse(updated)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to update voice settings for user={userId}: {e}", exc_info=True)
|
|
||||||
return JSONResponse({"error": str(e)}, status_code=500)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{instanceId}/voice/languages")
|
|
||||||
@limiter.limit("120/minute")
|
|
||||||
async def getVoiceLanguages(
|
|
||||||
request: Request,
|
|
||||||
instanceId: str = Path(...),
|
|
||||||
context: RequestContext = Depends(getRequestContext),
|
|
||||||
):
|
|
||||||
"""Return available TTS languages."""
|
|
||||||
mandateId, _ = _validateInstanceAccess(instanceId, context)
|
|
||||||
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
|
|
||||||
voiceInterface = getVoiceInterface(context.user, mandateId)
|
|
||||||
languagesResult = await voiceInterface.getAvailableLanguages()
|
|
||||||
languageList = languagesResult.get("languages", []) if isinstance(languagesResult, dict) else languagesResult
|
|
||||||
return JSONResponse({"languages": languageList})
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{instanceId}/voice/voices")
|
|
||||||
@limiter.limit("120/minute")
|
|
||||||
async def getVoiceVoices(
|
|
||||||
request: Request,
|
|
||||||
instanceId: str = Path(...),
|
|
||||||
language: str = Query("de-DE"),
|
|
||||||
context: RequestContext = Depends(getRequestContext),
|
|
||||||
):
|
|
||||||
"""Return available TTS voices for a given language."""
|
|
||||||
mandateId, _ = _validateInstanceAccess(instanceId, context)
|
|
||||||
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
|
|
||||||
voiceInterface = getVoiceInterface(context.user, mandateId)
|
|
||||||
voicesResult = await voiceInterface.getAvailableVoices(language)
|
|
||||||
voiceList = voicesResult.get("voices", []) if isinstance(voicesResult, dict) else voicesResult
|
|
||||||
return JSONResponse({"voices": voiceList})
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{instanceId}/voice/test")
|
|
||||||
@limiter.limit("30/minute")
|
|
||||||
async def testVoice(
|
|
||||||
request: Request,
|
|
||||||
instanceId: str = Path(...),
|
|
||||||
body: dict = Body(...),
|
|
||||||
context: RequestContext = Depends(getRequestContext),
|
|
||||||
):
|
|
||||||
"""Test a specific voice with a sample text."""
|
|
||||||
import base64
|
|
||||||
mandateId, _ = _validateInstanceAccess(instanceId, context)
|
|
||||||
text = body.get("text", "Hallo, das ist ein Stimmtest.")
|
|
||||||
language = body.get("language", "de-DE")
|
|
||||||
voiceId = body.get("voiceId")
|
|
||||||
|
|
||||||
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
|
|
||||||
voiceInterface = getVoiceInterface(context.user, mandateId)
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await voiceInterface.textToSpeech(text=text, languageCode=language, voiceName=voiceId)
|
|
||||||
if result and isinstance(result, dict):
|
|
||||||
audioContent = result.get("audioContent")
|
|
||||||
if audioContent:
|
|
||||||
audioB64 = base64.b64encode(
|
|
||||||
audioContent if isinstance(audioContent, bytes) else audioContent.encode()
|
|
||||||
).decode()
|
|
||||||
return JSONResponse({"success": True, "audio": audioB64, "format": "mp3", "text": text})
|
|
||||||
return JSONResponse({"success": False, "error": "TTS returned no audio"})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Voice test failed: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=f"TTS test failed: {str(e)}")
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,9 @@ Multi-Tenant Design:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional, Dict
|
from typing import Optional, Dict, Tuple
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
from modules.connectors.connectorDbPostgre import DatabaseConnector, _get_cached_connector
|
||||||
from modules.shared.configuration import APP_CONFIG
|
from modules.shared.configuration import APP_CONFIG
|
||||||
from modules.datamodels.datamodelUam import (
|
from modules.datamodels.datamodelUam import (
|
||||||
Mandate,
|
Mandate,
|
||||||
|
|
@ -38,6 +38,89 @@ pwdContext = CryptContext(schemes=["argon2"], deprecated="auto")
|
||||||
# Cache für Role-IDs (roleLabel -> roleId)
|
# Cache für Role-IDs (roleLabel -> roleId)
|
||||||
_roleIdCache: Dict[str, str] = {}
|
_roleIdCache: Dict[str, str] = {}
|
||||||
|
|
||||||
|
# PowerOn logical databases to scan (same set as gateway/scripts/script_db_export_migration.py ALL_DATABASES).
|
||||||
|
_POWERON_DATABASE_NAMES: Tuple[str, ...] = (
|
||||||
|
"poweron_app",
|
||||||
|
"poweron_automation",
|
||||||
|
"poweron_automation2",
|
||||||
|
"poweron_billing",
|
||||||
|
"poweron_chat",
|
||||||
|
"poweron_chatbot",
|
||||||
|
"poweron_commcoach",
|
||||||
|
"poweron_knowledge",
|
||||||
|
"poweron_management",
|
||||||
|
"poweron_neutralization",
|
||||||
|
"poweron_realestate",
|
||||||
|
"poweron_teamsbot",
|
||||||
|
"poweron_test",
|
||||||
|
"poweron_trustee",
|
||||||
|
"poweron_workspace",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _configPrefixForPoweronDatabase(dbName: str) -> str:
|
||||||
|
return {
|
||||||
|
"poweron_app": "DB_APP",
|
||||||
|
"poweron_chat": "DB_CHAT",
|
||||||
|
"poweron_chatbot": "DB_CHATBOT",
|
||||||
|
"poweron_management": "DB_MANAGEMENT",
|
||||||
|
"poweron_realestate": "DB_REALESTATE",
|
||||||
|
"poweron_trustee": "DB_TRUSTEE",
|
||||||
|
# Same as initAutomationTemplates: default DB_* (not a separate DB_AUTOMATION_* prefix).
|
||||||
|
"poweron_automation": "DB",
|
||||||
|
"poweron_billing": "DB",
|
||||||
|
}.get(dbName, "DB")
|
||||||
|
|
||||||
|
|
||||||
|
def _openConnectorForPoweronDatabase(dbName: str) -> Optional[DatabaseConnector]:
|
||||||
|
"""Connect to a named PowerOn database using DB_* / DB_APP_* style config (shared with export script)."""
|
||||||
|
prefix = _configPrefixForPoweronDatabase(dbName)
|
||||||
|
host = APP_CONFIG.get(f"{prefix}_HOST") or APP_CONFIG.get("DB_HOST", "localhost")
|
||||||
|
user = APP_CONFIG.get(f"{prefix}_USER") or APP_CONFIG.get("DB_USER")
|
||||||
|
password = APP_CONFIG.get(f"{prefix}_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD_SECRET")
|
||||||
|
portRaw = APP_CONFIG.get(f"{prefix}_PORT") or APP_CONFIG.get("DB_PORT", 5432)
|
||||||
|
try:
|
||||||
|
port = int(portRaw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
port = 5432
|
||||||
|
if not user or not password:
|
||||||
|
logger.debug(
|
||||||
|
f"bootstrap: skip legacy _* -> sys* migration for {dbName} (missing credentials for {prefix})"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return _get_cached_connector(
|
||||||
|
dbHost=host,
|
||||||
|
dbDatabase=dbName,
|
||||||
|
dbUser=user,
|
||||||
|
dbPassword=password,
|
||||||
|
dbPort=port,
|
||||||
|
userId=None,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"bootstrap: cannot open {dbName} for legacy _* -> sys* migration: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def migrateLegacyUnderscoreSysColumnsAllPoweronDatabases() -> None:
|
||||||
|
"""
|
||||||
|
Run DatabaseConnector.migrateLegacyUnderscoreSysColumns on every configured PowerOn database.
|
||||||
|
Actual table scan and SQL live in the connector module.
|
||||||
|
"""
|
||||||
|
grandTotal = 0
|
||||||
|
for dbName in _POWERON_DATABASE_NAMES:
|
||||||
|
conn = _openConnectorForPoweronDatabase(dbName)
|
||||||
|
if not conn:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
grandTotal += conn.migrateLegacyUnderscoreSysColumns()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"bootstrap: migrateLegacyUnderscoreSysColumns failed for {dbName}: {e}")
|
||||||
|
if grandTotal:
|
||||||
|
logger.info(
|
||||||
|
f"bootstrap: legacy _* -> sys* migration total {grandTotal} cell(s) across PowerOn databases"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def initBootstrap(db: DatabaseConnector) -> None:
|
def initBootstrap(db: DatabaseConnector) -> None:
|
||||||
"""
|
"""
|
||||||
|
|
@ -50,6 +133,9 @@ def initBootstrap(db: DatabaseConnector) -> None:
|
||||||
|
|
||||||
# Initialize root mandate
|
# Initialize root mandate
|
||||||
mandateId = initRootMandate(db)
|
mandateId = initRootMandate(db)
|
||||||
|
|
||||||
|
# Copy legacy _createdAt/_createdBy/_modifiedAt/_modifiedBy into sys* on all PowerOn DBs (connector routine)
|
||||||
|
migrateLegacyUnderscoreSysColumnsAllPoweronDatabases()
|
||||||
|
|
||||||
# Migrate existing mandate records: description -> label
|
# Migrate existing mandate records: description -> label
|
||||||
_migrateMandateDescriptionToLabel(db)
|
_migrateMandateDescriptionToLabel(db)
|
||||||
|
|
@ -92,8 +178,38 @@ def initBootstrap(db: DatabaseConnector) -> None:
|
||||||
# Seed automation templates (after admin user exists)
|
# Seed automation templates (after admin user exists)
|
||||||
initAutomationTemplates(db, adminUserId)
|
initAutomationTemplates(db, adminUserId)
|
||||||
|
|
||||||
# Initialize feature instances for root mandate
|
# Run root-user migration (one-time, sets completion flag)
|
||||||
if mandateId:
|
migrationDone = False
|
||||||
|
try:
|
||||||
|
from modules.migration.migrateRootUsers import migrateRootUsers, _isMigrationCompleted
|
||||||
|
migrationDone = _isMigrationCompleted(db)
|
||||||
|
if not migrationDone:
|
||||||
|
# Create root instances first (needed for migration), then migrate
|
||||||
|
if mandateId:
|
||||||
|
initRootMandateFeatures(db, mandateId)
|
||||||
|
result = migrateRootUsers(db)
|
||||||
|
migrationDone = result.get("status") != "error"
|
||||||
|
else:
|
||||||
|
migrationDone = True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Root user migration failed: {e}")
|
||||||
|
|
||||||
|
# Run voice & documents migration (one-time, sets completion flag)
|
||||||
|
try:
|
||||||
|
from modules.migration.migrateVoiceAndDocuments import migrateVoiceAndDocuments
|
||||||
|
migrateVoiceAndDocuments(db)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Voice & documents migration failed: {e}")
|
||||||
|
|
||||||
|
# Backfill FileContentIndex scope fields from FileItem (one-time)
|
||||||
|
try:
|
||||||
|
from modules.migration.migrateRagScopeFields import runMigration as migrateRagScope
|
||||||
|
migrateRagScope(appDb=db)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"RAG scope fields migration failed: {e}")
|
||||||
|
|
||||||
|
# After migration: root mandate is purely technical — no feature instances
|
||||||
|
if not migrationDone and mandateId:
|
||||||
initRootMandateFeatures(db, mandateId)
|
initRootMandateFeatures(db, mandateId)
|
||||||
|
|
||||||
# Remove feature instances for features that no longer exist in the codebase
|
# Remove feature instances for features that no longer exist in the codebase
|
||||||
|
|
@ -110,18 +226,26 @@ def initBootstrap(db: DatabaseConnector) -> None:
|
||||||
# Auto-provision Stripe Products/Prices for paid plans (idempotent)
|
# Auto-provision Stripe Products/Prices for paid plans (idempotent)
|
||||||
_bootstrapStripePrices()
|
_bootstrapStripePrices()
|
||||||
|
|
||||||
|
# Purge soft-deleted mandates past 30-day retention
|
||||||
|
try:
|
||||||
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
|
rootIf = getRootInterface()
|
||||||
|
rootIf.purgeExpiredMandates(retentionDays=30)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Mandate retention purge failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
def initAutomationTemplates(dbApp: DatabaseConnector, adminUserId: Optional[str] = None) -> None:
|
def initAutomationTemplates(dbApp: DatabaseConnector, adminUserId: Optional[str] = None) -> None:
|
||||||
"""
|
"""
|
||||||
Seed initial automation templates from subAutomationTemplates.py.
|
Seed initial automation templates from subAutomationTemplates.py.
|
||||||
Only runs if no templates exist yet (bootstrap).
|
Only runs if no templates exist yet (bootstrap).
|
||||||
Creates templates with _createdBy = admin user (SysAdmin privilege).
|
Creates templates with sysCreatedBy = admin user (SysAdmin privilege).
|
||||||
|
|
||||||
NOTE: AutomationTemplate lives in poweron_automation database, not poweron_app!
|
NOTE: AutomationTemplate lives in poweron_automation database, not poweron_app!
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
dbApp: Database connector for poweron_app (used to get admin user if needed)
|
dbApp: Database connector for poweron_app (used to get admin user if needed)
|
||||||
adminUserId: Admin user ID for _createdBy field
|
adminUserId: Admin user ID for sysCreatedBy field
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
from modules.features.automation.subAutomationTemplates import AUTOMATION_TEMPLATES
|
from modules.features.automation.subAutomationTemplates import AUTOMATION_TEMPLATES
|
||||||
|
|
@ -2004,71 +2128,43 @@ 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 (PREPAY_MANDATE).
|
||||||
Root mandate uses PREPAY_USER model with default initial credit per user in settings (DEFAULT_USER_CREDIT_CHF at bootstrap only).
|
Creates mandate pool account and user audit accounts.
|
||||||
Creates billing accounts for ALL users regardless of billing model (for audit trail).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
mandateId: Root mandate ID
|
|
||||||
"""
|
"""
|
||||||
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 (
|
from modules.datamodels.datamodelBilling import BillingSettings
|
||||||
BillingSettings,
|
|
||||||
BillingModelEnum,
|
|
||||||
DEFAULT_USER_CREDIT_CHF,
|
|
||||||
parseBillingModelFromStoredValue,
|
|
||||||
)
|
|
||||||
|
|
||||||
billingInterface = _getRootInterface()
|
billingInterface = _getRootInterface()
|
||||||
appInterface = getAppRootInterface()
|
appInterface = getAppRootInterface()
|
||||||
|
|
||||||
# Check if settings already exist
|
|
||||||
existingSettings = billingInterface.getSettings(mandateId)
|
existingSettings = billingInterface.getSettings(mandateId)
|
||||||
if existingSettings:
|
if existingSettings:
|
||||||
logger.info("Billing settings for root mandate already exist")
|
logger.info("Billing settings for root mandate already exist")
|
||||||
else:
|
else:
|
||||||
settings = BillingSettings(
|
settings = BillingSettings(
|
||||||
mandateId=mandateId,
|
mandateId=mandateId,
|
||||||
billingModel=BillingModelEnum.PREPAY_USER,
|
|
||||||
defaultUserCredit=DEFAULT_USER_CREDIT_CHF,
|
|
||||||
warningThresholdPercent=10.0,
|
warningThresholdPercent=10.0,
|
||||||
notifyOnWarning=True
|
notifyOnWarning=True
|
||||||
)
|
)
|
||||||
|
|
||||||
billingInterface.createSettings(settings)
|
billingInterface.createSettings(settings)
|
||||||
logger.info(
|
logger.info("Created billing settings for root mandate: PREPAY_MANDATE")
|
||||||
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)
|
|
||||||
if existingSettings:
|
if existingSettings:
|
||||||
billingModel = parseBillingModelFromStoredValue(
|
billingInterface.getOrCreateMandateAccount(mandateId, initialBalance=0.0)
|
||||||
existingSettings.get("billingModel")
|
|
||||||
).value
|
|
||||||
|
|
||||||
# Initial balance depends on billing model
|
|
||||||
if billingModel == BillingModelEnum.PREPAY_USER.value:
|
|
||||||
initialBalance = float(existingSettings.get("defaultUserCredit", 0.0))
|
|
||||||
else:
|
|
||||||
initialBalance = 0.0 # PREPAY_MANDATE: budget on pool account
|
|
||||||
|
|
||||||
userMandates = appInterface.getUserMandatesByMandate(mandateId)
|
userMandates = appInterface.getUserMandatesByMandate(mandateId)
|
||||||
accountsCreated = 0
|
accountsCreated = 0
|
||||||
|
|
||||||
for um in userMandates:
|
for um in userMandates:
|
||||||
userId = um.get("userId") if isinstance(um, dict) else getattr(um, "userId", None)
|
userId = um.get("userId") if isinstance(um, dict) else getattr(um, "userId", None)
|
||||||
if userId:
|
if userId:
|
||||||
existingAccount = billingInterface.getUserAccount(mandateId, userId)
|
existingAccount = billingInterface.getUserAccount(mandateId, userId)
|
||||||
if not existingAccount:
|
if not existingAccount:
|
||||||
billingInterface.getOrCreateUserAccount(mandateId, userId, initialBalance=initialBalance)
|
billingInterface.getOrCreateUserAccount(mandateId, userId, initialBalance=0.0)
|
||||||
accountsCreated += 1
|
accountsCreated += 1
|
||||||
logger.debug(f"Created billing account for user {userId}")
|
|
||||||
|
|
||||||
if accountsCreated > 0:
|
if accountsCreated > 0:
|
||||||
logger.info(f"Created {accountsCreated} billing accounts for root mandate users with {initialBalance} CHF each")
|
logger.info(f"Created {accountsCreated} billing audit accounts for root mandate users")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to initialize root mandate billing (non-critical): {e}")
|
logger.warning(f"Failed to initialize root mandate billing (non-critical): {e}")
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -9,7 +9,7 @@ All billing data is stored in the poweron_billing database.
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Any, List, Optional, Union
|
from typing import Dict, Any, List, Optional, Union
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta, timezone
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||||
|
|
@ -24,19 +24,49 @@ from modules.datamodels.datamodelBilling import (
|
||||||
BillingSettings,
|
BillingSettings,
|
||||||
StripeWebhookEvent,
|
StripeWebhookEvent,
|
||||||
UsageStatistics,
|
UsageStatistics,
|
||||||
BillingModelEnum,
|
|
||||||
AccountTypeEnum,
|
|
||||||
TransactionTypeEnum,
|
TransactionTypeEnum,
|
||||||
ReferenceTypeEnum,
|
ReferenceTypeEnum,
|
||||||
PeriodTypeEnum,
|
PeriodTypeEnum,
|
||||||
BillingBalanceResponse,
|
BillingBalanceResponse,
|
||||||
BillingCheckResult,
|
BillingCheckResult,
|
||||||
parseBillingModelFromStoredValue,
|
STORAGE_PRICE_PER_GB_CHF,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _logBillingTransactionsMissingSysCreatedAt(rows: List[Dict[str, Any]], context: str) -> None:
|
||||||
|
"""Log ERROR when sysCreatedAt is missing; does not raise."""
|
||||||
|
missingIds = [r.get("id") for r in rows if r.get("sysCreatedAt") is None]
|
||||||
|
if not missingIds:
|
||||||
|
return
|
||||||
|
cap = 40
|
||||||
|
sample = missingIds[:cap]
|
||||||
|
suffix = f"; ... (+{len(missingIds) - cap} more)" if len(missingIds) > cap else ""
|
||||||
|
logger.error(
|
||||||
|
"BillingTransaction missing sysCreatedAt (%s): count=%s; transactionIds=%s%s",
|
||||||
|
context,
|
||||||
|
len(missingIds),
|
||||||
|
sample,
|
||||||
|
suffix,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _numericSysCreatedAtForSort(row: Dict[str, Any]) -> float:
|
||||||
|
v = row["sysCreatedAt"]
|
||||||
|
if isinstance(v, datetime):
|
||||||
|
return v.timestamp()
|
||||||
|
return float(v)
|
||||||
|
|
||||||
|
|
||||||
|
def _sortBillingTransactionsBySysCreatedAtDesc(rows: List[Dict[str, Any]], context: str) -> None:
|
||||||
|
_logBillingTransactionsMissingSysCreatedAt(rows, context)
|
||||||
|
valid = [r for r in rows if r.get("sysCreatedAt") is not None]
|
||||||
|
invalid = [r for r in rows if r.get("sysCreatedAt") is None]
|
||||||
|
valid.sort(key=_numericSysCreatedAtForSort, reverse=True)
|
||||||
|
rows[:] = valid + invalid
|
||||||
|
|
||||||
|
|
||||||
def _getAppDatabaseConnector() -> DatabaseConnector:
|
def _getAppDatabaseConnector() -> DatabaseConnector:
|
||||||
"""App DB connector (same config as UserMandate reads in this module)."""
|
"""App DB connector (same config as UserMandate reads in this module)."""
|
||||||
return DatabaseConnector(
|
return DatabaseConnector(
|
||||||
|
|
@ -160,8 +190,6 @@ 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
|
||||||
|
|
||||||
|
|
@ -175,27 +203,7 @@ class BillingObjects:
|
||||||
)
|
)
|
||||||
if not results:
|
if not results:
|
||||||
return None
|
return None
|
||||||
row = dict(results[0])
|
return 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
|
||||||
|
|
@ -226,13 +234,12 @@ class BillingObjects:
|
||||||
"""
|
"""
|
||||||
return self.db.recordModify(BillingSettings, settingsId, updates)
|
return self.db.recordModify(BillingSettings, settingsId, updates)
|
||||||
|
|
||||||
def getOrCreateSettings(self, mandateId: str, defaultModel: BillingModelEnum = BillingModelEnum.PREPAY_MANDATE) -> Dict[str, Any]:
|
def getOrCreateSettings(self, mandateId: str) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get or create billing settings for a mandate.
|
Get or create billing settings for a mandate.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
mandateId: Mandate ID
|
mandateId: Mandate ID
|
||||||
defaultModel: Default billing model if creating
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
BillingSettings dict
|
BillingSettings dict
|
||||||
|
|
@ -243,8 +250,6 @@ class BillingObjects:
|
||||||
|
|
||||||
settings = BillingSettings(
|
settings = BillingSettings(
|
||||||
mandateId=mandateId,
|
mandateId=mandateId,
|
||||||
billingModel=defaultModel,
|
|
||||||
defaultUserCredit=0.0,
|
|
||||||
warningThresholdPercent=10.0,
|
warningThresholdPercent=10.0,
|
||||||
notifyOnWarning=True,
|
notifyOnWarning=True,
|
||||||
)
|
)
|
||||||
|
|
@ -281,7 +286,7 @@ class BillingObjects:
|
||||||
BillingAccount,
|
BillingAccount,
|
||||||
recordFilter={
|
recordFilter={
|
||||||
"mandateId": mandateId,
|
"mandateId": mandateId,
|
||||||
"accountType": AccountTypeEnum.MANDATE.value
|
"userId": None
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return results[0] if results else None
|
return results[0] if results else None
|
||||||
|
|
@ -305,8 +310,7 @@ class BillingObjects:
|
||||||
BillingAccount,
|
BillingAccount,
|
||||||
recordFilter={
|
recordFilter={
|
||||||
"mandateId": mandateId,
|
"mandateId": mandateId,
|
||||||
"userId": userId,
|
"userId": userId
|
||||||
"accountType": AccountTypeEnum.USER.value
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return results[0] if results else None
|
return results[0] if results else None
|
||||||
|
|
@ -376,7 +380,6 @@ class BillingObjects:
|
||||||
|
|
||||||
account = BillingAccount(
|
account = BillingAccount(
|
||||||
mandateId=mandateId,
|
mandateId=mandateId,
|
||||||
accountType=AccountTypeEnum.MANDATE,
|
|
||||||
balance=initialBalance,
|
balance=initialBalance,
|
||||||
enabled=True
|
enabled=True
|
||||||
)
|
)
|
||||||
|
|
@ -401,7 +404,6 @@ class BillingObjects:
|
||||||
account = BillingAccount(
|
account = BillingAccount(
|
||||||
mandateId=mandateId,
|
mandateId=mandateId,
|
||||||
userId=userId,
|
userId=userId,
|
||||||
accountType=AccountTypeEnum.USER,
|
|
||||||
balance=initialBalance,
|
balance=initialBalance,
|
||||||
enabled=True
|
enabled=True
|
||||||
)
|
)
|
||||||
|
|
@ -422,7 +424,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_MANDATE, 0 CHF) for mandates without settings.
|
Creates default settings (0 CHF) for mandates without settings.
|
||||||
Uses bulk queries to minimize database connections.
|
Uses bulk queries to minimize database connections.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|
@ -451,16 +453,13 @@ class BillingObjects:
|
||||||
if not mandateId or mandateId in existingMandateIds:
|
if not mandateId or mandateId in existingMandateIds:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Create default billing settings
|
|
||||||
settings = BillingSettings(
|
settings = BillingSettings(
|
||||||
mandateId=mandateId,
|
mandateId=mandateId,
|
||||||
billingModel=BillingModelEnum.PREPAY_MANDATE,
|
|
||||||
defaultUserCredit=0.0,
|
|
||||||
warningThresholdPercent=10.0,
|
warningThresholdPercent=10.0,
|
||||||
notifyOnWarning=True,
|
notifyOnWarning=True,
|
||||||
)
|
)
|
||||||
self.createSettings(settings)
|
self.createSettings(settings)
|
||||||
existingMandateIds.add(mandateId) # Track newly created
|
existingMandateIds.add(mandateId)
|
||||||
settingsCreated += 1
|
settingsCreated += 1
|
||||||
|
|
||||||
if settingsCreated > 0:
|
if settingsCreated > 0:
|
||||||
|
|
@ -475,11 +474,7 @@ class BillingObjects:
|
||||||
def ensureAllUserAccountsExist(self) -> int:
|
def ensureAllUserAccountsExist(self) -> int:
|
||||||
"""
|
"""
|
||||||
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 for audit trail with initial balance 0.0.
|
||||||
Initial balance depends on billing model:
|
|
||||||
- PREPAY_USER: defaultUserCredit from settings only for the root mandate; other mandates get 0.0
|
|
||||||
- PREPAY_MANDATE: 0.0 (budget is on pool)
|
|
||||||
|
|
||||||
Uses bulk queries to minimize database connections.
|
Uses bulk queries to minimize database connections.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|
@ -488,44 +483,29 @@ class BillingObjects:
|
||||||
try:
|
try:
|
||||||
accountsCreated = 0
|
accountsCreated = 0
|
||||||
appDb = _getAppDatabaseConnector()
|
appDb = _getAppDatabaseConnector()
|
||||||
rootMandateId = _getCachedRootMandateId()
|
|
||||||
|
|
||||||
# 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)
|
billingMandateIds = set(
|
||||||
for s in allSettings:
|
s.get("mandateId") for s in allSettings if s.get("mandateId")
|
||||||
billingModel = parseBillingModelFromStoredValue(s.get("billingModel")).value
|
)
|
||||||
mid = s.get("mandateId")
|
|
||||||
isRoot = rootMandateId is not None and str(mid) == str(rootMandateId)
|
|
||||||
if billingModel == BillingModelEnum.PREPAY_USER.value:
|
|
||||||
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 billingMandateIds:
|
||||||
logger.debug("No billable mandates found, skipping account check")
|
logger.debug("No billable mandates found, skipping account check")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# Step 2: Get all existing USER accounts in one query
|
allAccounts = self.db.getRecordset(BillingAccount)
|
||||||
allAccounts = self.db.getRecordset(
|
|
||||||
BillingAccount,
|
|
||||||
recordFilter={"accountType": AccountTypeEnum.USER.value}
|
|
||||||
)
|
|
||||||
existingAccountKeys = set()
|
existingAccountKeys = set()
|
||||||
for acc in allAccounts:
|
for acc in allAccounts:
|
||||||
|
if not acc.get("userId"):
|
||||||
|
continue
|
||||||
key = (acc.get("mandateId"), acc.get("userId"))
|
key = (acc.get("mandateId"), acc.get("userId"))
|
||||||
existingAccountKeys.add(key)
|
existingAccountKeys.add(key)
|
||||||
|
|
||||||
# Step 3: Get all user-mandate combinations from APP database
|
|
||||||
allUserMandates = appDb.getRecordset(
|
allUserMandates = appDb.getRecordset(
|
||||||
UserMandate,
|
UserMandate,
|
||||||
recordFilter={"enabled": True}
|
recordFilter={"enabled": True}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 4: Create missing accounts
|
|
||||||
for um in allUserMandates:
|
for um in allUserMandates:
|
||||||
mandateId = um.get("mandateId")
|
mandateId = um.get("mandateId")
|
||||||
userId = um.get("userId")
|
userId = um.get("userId")
|
||||||
|
|
@ -533,32 +513,20 @@ class BillingObjects:
|
||||||
if not mandateId or not userId:
|
if not mandateId or not userId:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if mandateId not in billingMandates:
|
if mandateId not in billingMandateIds:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
key = (mandateId, userId)
|
key = (mandateId, userId)
|
||||||
if key in existingAccountKeys:
|
if key in existingAccountKeys:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
billingModel, defaultCredit = billingMandates[mandateId]
|
|
||||||
|
|
||||||
account = BillingAccount(
|
account = BillingAccount(
|
||||||
mandateId=mandateId,
|
mandateId=mandateId,
|
||||||
userId=userId,
|
userId=userId,
|
||||||
accountType=AccountTypeEnum.USER,
|
balance=0.0,
|
||||||
balance=defaultCredit,
|
|
||||||
enabled=True
|
enabled=True
|
||||||
)
|
)
|
||||||
created = self.createAccount(account)
|
self.createAccount(account)
|
||||||
|
|
||||||
if defaultCredit > 0:
|
|
||||||
self.createTransaction(BillingTransaction(
|
|
||||||
accountId=created["id"],
|
|
||||||
transactionType=TransactionTypeEnum.CREDIT,
|
|
||||||
amount=defaultCredit,
|
|
||||||
description="Initial credit for new user",
|
|
||||||
referenceType=ReferenceTypeEnum.SYSTEM
|
|
||||||
))
|
|
||||||
|
|
||||||
existingAccountKeys.add(key)
|
existingAccountKeys.add(key)
|
||||||
accountsCreated += 1
|
accountsCreated += 1
|
||||||
|
|
@ -662,6 +630,10 @@ class BillingObjects:
|
||||||
pagination=pagination,
|
pagination=pagination,
|
||||||
recordFilter=recordFilter
|
recordFilter=recordFilter
|
||||||
)
|
)
|
||||||
|
_logBillingTransactionsMissingSysCreatedAt(
|
||||||
|
result["items"],
|
||||||
|
"getTransactions(accountId) paginated",
|
||||||
|
)
|
||||||
return PaginatedResult(
|
return PaginatedResult(
|
||||||
items=result["items"],
|
items=result["items"],
|
||||||
totalItems=result["totalItems"],
|
totalItems=result["totalItems"],
|
||||||
|
|
@ -674,7 +646,7 @@ class BillingObjects:
|
||||||
if startDate or endDate:
|
if startDate or endDate:
|
||||||
filtered = []
|
filtered = []
|
||||||
for t in results:
|
for t in results:
|
||||||
createdAt = t.get("_createdAt")
|
createdAt = t.get("sysCreatedAt")
|
||||||
if createdAt:
|
if createdAt:
|
||||||
tDate = createdAt.date() if isinstance(createdAt, datetime) else createdAt
|
tDate = createdAt.date() if isinstance(createdAt, datetime) else createdAt
|
||||||
if startDate and tDate < startDate:
|
if startDate and tDate < startDate:
|
||||||
|
|
@ -684,7 +656,7 @@ class BillingObjects:
|
||||||
filtered.append(t)
|
filtered.append(t)
|
||||||
results = filtered
|
results = filtered
|
||||||
|
|
||||||
results.sort(key=lambda x: x.get("_createdAt", ""), reverse=True)
|
_sortBillingTransactionsBySysCreatedAtDesc(results, "getTransactions(accountId)")
|
||||||
|
|
||||||
return results[offset:offset + limit]
|
return results[offset:offset + limit]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -739,7 +711,10 @@ class BillingObjects:
|
||||||
transactions = self.getTransactions(account["id"], limit=limit)
|
transactions = self.getTransactions(account["id"], limit=limit)
|
||||||
allTransactions.extend(transactions)
|
allTransactions.extend(transactions)
|
||||||
|
|
||||||
allTransactions.sort(key=lambda x: x.get("_createdAt", ""), reverse=True)
|
_sortBillingTransactionsBySysCreatedAtDesc(
|
||||||
|
allTransactions,
|
||||||
|
"getTransactionsByMandate",
|
||||||
|
)
|
||||||
return allTransactions[:limit]
|
return allTransactions[:limit]
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
@ -810,35 +785,14 @@ class BillingObjects:
|
||||||
"""
|
"""
|
||||||
Check if there's sufficient balance for an operation.
|
Check if there's sufficient balance for an operation.
|
||||||
|
|
||||||
- PREPAY_USER: user.balance >= estimatedCost
|
Checks mandate pool balance against estimatedCost.
|
||||||
- PREPAY_MANDATE: mandate pool balance >= estimatedCost
|
User accounts are ensured to exist for audit trail.
|
||||||
|
Missing settings: treated as PREPAY_MANDATE with empty pool.
|
||||||
User accounts are always ensured to exist (for audit trail).
|
|
||||||
Root mandate + PREPAY_USER: initial credit from settings.defaultUserCredit on first create.
|
|
||||||
Missing settings: treated as PREPAY_MANDATE with empty pool (strict).
|
|
||||||
"""
|
"""
|
||||||
settings = self.getSettings(mandateId)
|
self.getOrCreateUserAccount(mandateId, userId, initialBalance=0.0)
|
||||||
if not settings:
|
|
||||||
billingModel = BillingModelEnum.PREPAY_MANDATE
|
|
||||||
defaultCredit = 0.0
|
|
||||||
else:
|
|
||||||
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
|
|
||||||
defaultCredit = float(settings.get("defaultUserCredit", 0.0) or 0.0)
|
|
||||||
|
|
||||||
rootMandateId = _getCachedRootMandateId()
|
poolAccount = self.getOrCreateMandateAccount(mandateId)
|
||||||
isRootMandate = rootMandateId is not None and str(mandateId) == str(rootMandateId)
|
currentBalance = poolAccount.get("balance", 0.0)
|
||||||
if billingModel == BillingModelEnum.PREPAY_USER:
|
|
||||||
initialBalance = defaultCredit if isRootMandate else 0.0
|
|
||||||
else:
|
|
||||||
initialBalance = 0.0
|
|
||||||
self.getOrCreateUserAccount(mandateId, userId, initialBalance=initialBalance)
|
|
||||||
|
|
||||||
if billingModel == BillingModelEnum.PREPAY_USER:
|
|
||||||
account = self.getUserAccount(mandateId, userId)
|
|
||||||
currentBalance = account.get("balance", 0.0) if account else 0.0
|
|
||||||
else:
|
|
||||||
poolAccount = self.getOrCreateMandateAccount(mandateId)
|
|
||||||
currentBalance = poolAccount.get("balance", 0.0)
|
|
||||||
|
|
||||||
if currentBalance < estimatedCost:
|
if currentBalance < estimatedCost:
|
||||||
return BillingCheckResult(
|
return BillingCheckResult(
|
||||||
|
|
@ -846,10 +800,9 @@ class BillingObjects:
|
||||||
reason="INSUFFICIENT_BALANCE",
|
reason="INSUFFICIENT_BALANCE",
|
||||||
currentBalance=currentBalance,
|
currentBalance=currentBalance,
|
||||||
requiredAmount=estimatedCost,
|
requiredAmount=estimatedCost,
|
||||||
billingModel=billingModel,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return BillingCheckResult(allowed=True, currentBalance=currentBalance, billingModel=billingModel)
|
return BillingCheckResult(allowed=True, currentBalance=currentBalance)
|
||||||
|
|
||||||
def recordUsage(
|
def recordUsage(
|
||||||
self,
|
self,
|
||||||
|
|
@ -870,10 +823,8 @@ class BillingObjects:
|
||||||
"""
|
"""
|
||||||
Record usage cost as a billing transaction.
|
Record usage cost as a billing transaction.
|
||||||
|
|
||||||
Transaction is ALWAYS recorded on the user's account (clean audit trail).
|
Transaction is recorded on the user's account (audit trail).
|
||||||
Balance is deducted from the appropriate account based on billing model:
|
Balance is always deducted from the mandate pool account (PREPAY_MANDATE).
|
||||||
- PREPAY_USER: deduct from user's own balance
|
|
||||||
- PREPAY_MANDATE: deduct from mandate pool balance
|
|
||||||
"""
|
"""
|
||||||
if priceCHF <= 0:
|
if priceCHF <= 0:
|
||||||
return None
|
return None
|
||||||
|
|
@ -883,9 +834,6 @@ 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 = parseBillingModelFromStoredValue(settings.get("billingModel"))
|
|
||||||
|
|
||||||
# Transaction is ALWAYS on the user's account (audit trail)
|
|
||||||
userAccount = self.getOrCreateUserAccount(mandateId, userId)
|
userAccount = self.getOrCreateUserAccount(mandateId, userId)
|
||||||
|
|
||||||
transaction = BillingTransaction(
|
transaction = BillingTransaction(
|
||||||
|
|
@ -906,18 +854,150 @@ class BillingObjects:
|
||||||
errorCount=errorCount
|
errorCount=errorCount
|
||||||
)
|
)
|
||||||
|
|
||||||
# Determine where to deduct balance
|
poolAccount = self.getOrCreateMandateAccount(mandateId)
|
||||||
if billingModel == BillingModelEnum.PREPAY_USER:
|
return self.createTransaction(transaction, balanceAccountId=poolAccount["id"])
|
||||||
return self.createTransaction(transaction)
|
|
||||||
if billingModel == BillingModelEnum.PREPAY_MANDATE:
|
def _parseSettingsDateTime(self, value: Any) -> Optional[datetime]:
|
||||||
poolAccount = self.getOrCreateMandateAccount(mandateId)
|
"""Parse datetime from billing settings row (ISO string or datetime)."""
|
||||||
return self.createTransaction(transaction, balanceAccountId=poolAccount["id"])
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
if value.tzinfo:
|
||||||
|
return value.astimezone(timezone.utc)
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
if isinstance(value, str):
|
||||||
|
s = value.replace("Z", "+00:00")
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(s)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if dt.tzinfo:
|
||||||
|
return dt.astimezone(timezone.utc)
|
||||||
|
return dt.replace(tzinfo=timezone.utc)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def resetStorageBillingPeriod(self, mandateId: str, periodStartAt: datetime) -> None:
|
||||||
|
"""Reset storage watermark state for a new subscription billing period (e.g. Stripe invoice.paid)."""
|
||||||
|
if periodStartAt.tzinfo is None:
|
||||||
|
periodStartAt = periodStartAt.replace(tzinfo=timezone.utc)
|
||||||
|
else:
|
||||||
|
periodStartAt = periodStartAt.astimezone(timezone.utc)
|
||||||
|
settings = self.getOrCreateSettings(mandateId)
|
||||||
|
prev = self._parseSettingsDateTime(settings.get("storagePeriodStartAt"))
|
||||||
|
if prev is not None and abs((prev - periodStartAt).total_seconds()) < 2:
|
||||||
|
return
|
||||||
|
from modules.interfaces.interfaceDbSubscription import _getRootInterface as _getSubRoot
|
||||||
|
|
||||||
|
usedMB = float(_getSubRoot().getMandateDataVolumeMB(mandateId))
|
||||||
|
self.updateSettings(
|
||||||
|
settings["id"],
|
||||||
|
{
|
||||||
|
"storageHighWatermarkMB": usedMB,
|
||||||
|
"storageBilledUpToMB": 0.0,
|
||||||
|
"storagePeriodStartAt": periodStartAt,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Storage billing period reset for mandate %s at %s (usedMB=%.2f)",
|
||||||
|
mandateId,
|
||||||
|
periodStartAt.isoformat(),
|
||||||
|
usedMB,
|
||||||
|
)
|
||||||
|
|
||||||
|
def reconcileMandateStorageBilling(self, mandateId: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Debit prepay pool for new storage overage using period high-watermark (no credit on delete)."""
|
||||||
|
settings = self.getSettings(mandateId)
|
||||||
|
if not settings:
|
||||||
|
return None
|
||||||
|
from modules.interfaces.interfaceDbSubscription import _getRootInterface as _getSubRoot
|
||||||
|
from modules.datamodels.datamodelSubscription import _getPlan
|
||||||
|
|
||||||
|
subIface = _getSubRoot()
|
||||||
|
usedMB = float(subIface.getMandateDataVolumeMB(mandateId))
|
||||||
|
sub = subIface.getOperativeForMandate(mandateId)
|
||||||
|
plan = _getPlan(sub.get("planKey", "")) if sub else None
|
||||||
|
includedMB = plan.maxDataVolumeMB if plan and plan.maxDataVolumeMB is not None else None
|
||||||
|
if includedMB is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
prevHigh = float(settings.get("storageHighWatermarkMB") or 0.0)
|
||||||
|
high = max(prevHigh, usedMB)
|
||||||
|
overageMB = max(0.0, high - float(includedMB))
|
||||||
|
billed = float(settings.get("storageBilledUpToMB") or 0.0)
|
||||||
|
deltaOverage = overageMB - billed
|
||||||
|
settingsUpdates: Dict[str, Any] = {}
|
||||||
|
if high != prevHigh:
|
||||||
|
settingsUpdates["storageHighWatermarkMB"] = high
|
||||||
|
if deltaOverage <= 1e-9:
|
||||||
|
if settingsUpdates:
|
||||||
|
self.updateSettings(settings["id"], settingsUpdates)
|
||||||
|
return None
|
||||||
|
|
||||||
|
costCHF = round((deltaOverage / 1024.0) * float(STORAGE_PRICE_PER_GB_CHF), 4)
|
||||||
|
if costCHF <= 0:
|
||||||
|
if settingsUpdates:
|
||||||
|
self.updateSettings(settings["id"], settingsUpdates)
|
||||||
|
return None
|
||||||
|
|
||||||
|
poolAccount = self.getOrCreateMandateAccount(mandateId)
|
||||||
|
transaction = BillingTransaction(
|
||||||
|
accountId=poolAccount["id"],
|
||||||
|
transactionType=TransactionTypeEnum.DEBIT,
|
||||||
|
amount=costCHF,
|
||||||
|
description=f"Speicher-Überhang ({deltaOverage:.2f} MB über Plan)",
|
||||||
|
referenceType=ReferenceTypeEnum.STORAGE,
|
||||||
|
referenceId=mandateId,
|
||||||
|
)
|
||||||
|
created = self.createTransaction(transaction)
|
||||||
|
settingsUpdates["storageBilledUpToMB"] = overageMB
|
||||||
|
self.updateSettings(settings["id"], settingsUpdates)
|
||||||
|
logger.info(
|
||||||
|
"Storage overage billed mandate=%s deltaOverageMB=%.4f costCHF=%s",
|
||||||
|
mandateId,
|
||||||
|
deltaOverage,
|
||||||
|
costCHF,
|
||||||
|
)
|
||||||
|
return created
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Subscription AI-Budget Credit
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def creditSubscriptionBudget(self, mandateId: str, planKey: str, periodLabel: str = "") -> Optional[Dict[str, Any]]:
|
||||||
|
"""Credit the plan's budgetAiCHF to the mandate pool account.
|
||||||
|
|
||||||
|
Should be called once per billing period (initial activation + each invoice.paid).
|
||||||
|
Returns the created CREDIT transaction or None if budget is 0."""
|
||||||
|
from modules.datamodels.datamodelSubscription import _getPlan
|
||||||
|
|
||||||
|
plan = _getPlan(planKey)
|
||||||
|
if not plan or not plan.budgetAiCHF or plan.budgetAiCHF <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
poolAccount = self.getOrCreateMandateAccount(mandateId)
|
||||||
|
description = f"AI-Budget ({planKey})"
|
||||||
|
if periodLabel:
|
||||||
|
description += f" – {periodLabel}"
|
||||||
|
|
||||||
|
transaction = BillingTransaction(
|
||||||
|
accountId=poolAccount["id"],
|
||||||
|
transactionType=TransactionTypeEnum.CREDIT,
|
||||||
|
amount=plan.budgetAiCHF,
|
||||||
|
description=description,
|
||||||
|
referenceType=ReferenceTypeEnum.SUBSCRIPTION,
|
||||||
|
referenceId=mandateId,
|
||||||
|
)
|
||||||
|
created = self.createTransaction(transaction)
|
||||||
|
logger.info(
|
||||||
|
"AI-Budget credited mandate=%s plan=%s amount=%.2f CHF",
|
||||||
|
mandateId, planKey, plan.budgetAiCHF,
|
||||||
|
)
|
||||||
|
return created
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Workflow Cost Query
|
# Workflow Cost Query
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
def getWorkflowCost(self, workflowId: str) -> float:
|
def getWorkflowCost(self, workflowId: str) -> float:
|
||||||
"""Sum of all transaction amounts for a workflow."""
|
"""Sum of all transaction amounts for a workflow."""
|
||||||
if not workflowId:
|
if not workflowId:
|
||||||
|
|
@ -928,112 +1008,6 @@ class BillingObjects:
|
||||||
)
|
)
|
||||||
return sum(t.get("amount", 0.0) for t in transactions)
|
return sum(t.get("amount", 0.0) for t in transactions)
|
||||||
|
|
||||||
# =========================================================================
|
|
||||||
# Billing Model Switch Operations
|
|
||||||
# =========================================================================
|
|
||||||
|
|
||||||
def switchBillingModel(self, mandateId: str, oldModel: BillingModelEnum, newModel: BillingModelEnum) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Switch billing model with budget migration logged as BillingTransactions.
|
|
||||||
|
|
||||||
PREPAY_MANDATE -> PREPAY_USER: pool debited, equal shares credited to user accounts.
|
|
||||||
PREPAY_USER -> PREPAY_MANDATE: user wallets debited, pool credited with sum.
|
|
||||||
"""
|
|
||||||
result = {"oldModel": oldModel.value, "newModel": newModel.value, "migratedAmount": 0.0, "userCount": 0}
|
|
||||||
|
|
||||||
if oldModel == newModel:
|
|
||||||
return result
|
|
||||||
|
|
||||||
if oldModel == BillingModelEnum.PREPAY_MANDATE and newModel == BillingModelEnum.PREPAY_USER:
|
|
||||||
poolAccount = self.getMandateAccount(mandateId)
|
|
||||||
userAccounts = self.db.getRecordset(
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
result["migratedAmount"] = poolBalance
|
|
||||||
if n > 0:
|
|
||||||
remaining = poolBalance
|
|
||||||
for i, acc in enumerate(userAccounts):
|
|
||||||
if i == n - 1:
|
|
||||||
share = round(remaining, 4)
|
|
||||||
else:
|
|
||||||
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
|
|
||||||
|
|
||||||
if oldModel == BillingModelEnum.PREPAY_USER and newModel == BillingModelEnum.PREPAY_MANDATE:
|
|
||||||
userAccounts = self.db.getRecordset(
|
|
||||||
BillingAccount,
|
|
||||||
recordFilter={"mandateId": mandateId, "accountType": AccountTypeEnum.USER.value}
|
|
||||||
)
|
|
||||||
totalUserBalance = sum(acc.get("balance", 0.0) for acc in userAccounts)
|
|
||||||
for acc in userAccounts:
|
|
||||||
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["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
|
|
||||||
|
|
||||||
if newModel == BillingModelEnum.PREPAY_MANDATE:
|
|
||||||
self.getOrCreateMandateAccount(mandateId, initialBalance=0.0)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Statistics Operations
|
# Statistics Operations
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
@ -1128,10 +1102,8 @@ class BillingObjects:
|
||||||
def getBalancesForUser(self, userId: str) -> List[BillingBalanceResponse]:
|
def getBalancesForUser(self, userId: str) -> List[BillingBalanceResponse]:
|
||||||
"""
|
"""
|
||||||
Get all billing balances for a user across mandates.
|
Get all billing balances for a user across mandates.
|
||||||
|
Shows the mandate pool balance (shared budget visible to user).
|
||||||
|
|
||||||
Shows the effective available budget:
|
|
||||||
- PREPAY_USER: user's own account balance
|
|
||||||
- PREPAY_MANDATE: mandate pool balance (shared budget visible to user)
|
|
||||||
Args:
|
Args:
|
||||||
userId: User ID
|
userId: User ID
|
||||||
|
|
||||||
|
|
@ -1163,27 +1135,15 @@ class BillingObjects:
|
||||||
if not settings:
|
if not settings:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
|
poolAccount = self.getOrCreateMandateAccount(mandateId)
|
||||||
|
if not poolAccount:
|
||||||
if billingModel == BillingModelEnum.PREPAY_USER:
|
|
||||||
account = self.getOrCreateUserAccount(mandateId, userId)
|
|
||||||
if not account:
|
|
||||||
continue
|
|
||||||
balance = account.get("balance", 0.0)
|
|
||||||
warningThreshold = account.get("warningThreshold", 0.0)
|
|
||||||
elif billingModel == BillingModelEnum.PREPAY_MANDATE:
|
|
||||||
poolAccount = self.getOrCreateMandateAccount(mandateId)
|
|
||||||
if not poolAccount:
|
|
||||||
continue
|
|
||||||
balance = poolAccount.get("balance", 0.0)
|
|
||||||
warningThreshold = poolAccount.get("warningThreshold", 0.0)
|
|
||||||
else:
|
|
||||||
continue
|
continue
|
||||||
|
balance = poolAccount.get("balance", 0.0)
|
||||||
|
warningThreshold = poolAccount.get("warningThreshold", 0.0)
|
||||||
|
|
||||||
balances.append(BillingBalanceResponse(
|
balances.append(BillingBalanceResponse(
|
||||||
mandateId=mandateId,
|
mandateId=mandateId,
|
||||||
mandateName=mandateName,
|
mandateName=mandateName,
|
||||||
billingModel=billingModel,
|
|
||||||
balance=balance,
|
balance=balance,
|
||||||
warningThreshold=warningThreshold,
|
warningThreshold=warningThreshold,
|
||||||
isWarning=balance <= warningThreshold,
|
isWarning=balance <= warningThreshold,
|
||||||
|
|
@ -1244,7 +1204,7 @@ class BillingObjects:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting transactions for user: {e}")
|
logger.error(f"Error getting transactions for user: {e}")
|
||||||
|
|
||||||
allTransactions.sort(key=lambda x: x.get("_createdAt", ""), reverse=True)
|
_sortBillingTransactionsBySysCreatedAtDesc(allTransactions, "getTransactionsForUser")
|
||||||
return allTransactions[:limit]
|
return allTransactions[:limit]
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
@ -1280,36 +1240,25 @@ class BillingObjects:
|
||||||
if not mandateId:
|
if not mandateId:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
|
|
||||||
|
|
||||||
# Get mandate info
|
|
||||||
mandate = appInterface.getMandate(mandateId)
|
mandate = appInterface.getMandate(mandateId)
|
||||||
mandateName = ""
|
mandateName = ""
|
||||||
if mandate:
|
if mandate:
|
||||||
mandateName = getattr(mandate, 'label', None) or getattr(mandate, 'name', None) or (mandate.get("label") or mandate.get("name", "") if isinstance(mandate, dict) else "")
|
mandateName = getattr(mandate, 'label', None) or getattr(mandate, 'name', None) or (mandate.get("label") or mandate.get("name", "") if isinstance(mandate, dict) else "")
|
||||||
|
|
||||||
# Get user accounts count (always exist now for audit trail)
|
allMandateAccounts = self.db.getRecordset(
|
||||||
userAccounts = self.db.getRecordset(
|
|
||||||
BillingAccount,
|
BillingAccount,
|
||||||
recordFilter={"mandateId": mandateId, "accountType": AccountTypeEnum.USER.value}
|
recordFilter={"mandateId": mandateId}
|
||||||
)
|
)
|
||||||
userCount = len(userAccounts)
|
userCount = sum(1 for acc in allMandateAccounts if acc.get("userId"))
|
||||||
|
|
||||||
if billingModel == BillingModelEnum.PREPAY_USER:
|
poolAccount = self.getMandateAccount(mandateId)
|
||||||
totalBalance = sum(acc.get("balance", 0.0) for acc in userAccounts)
|
totalBalance = poolAccount.get("balance", 0.0) if poolAccount else 0.0
|
||||||
elif billingModel == BillingModelEnum.PREPAY_MANDATE:
|
|
||||||
poolAccount = self.getMandateAccount(mandateId)
|
|
||||||
totalBalance = poolAccount.get("balance", 0.0) if poolAccount else 0.0
|
|
||||||
else:
|
|
||||||
totalBalance = 0.0
|
|
||||||
|
|
||||||
balances.append({
|
balances.append({
|
||||||
"mandateId": mandateId,
|
"mandateId": mandateId,
|
||||||
"mandateName": mandateName,
|
"mandateName": mandateName,
|
||||||
"billingModel": billingModel.value,
|
|
||||||
"totalBalance": totalBalance,
|
"totalBalance": totalBalance,
|
||||||
"userCount": userCount,
|
"userCount": userCount,
|
||||||
"defaultUserCredit": float(settings.get("defaultUserCredit", 0.0) or 0.0),
|
|
||||||
"warningThresholdPercent": settings.get("warningThresholdPercent", 10.0),
|
"warningThresholdPercent": settings.get("warningThresholdPercent", 10.0),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -1361,7 +1310,7 @@ class BillingObjects:
|
||||||
logger.error(f"Error getting mandate transactions: {e}")
|
logger.error(f"Error getting mandate transactions: {e}")
|
||||||
|
|
||||||
# Sort by creation date descending and limit
|
# Sort by creation date descending and limit
|
||||||
allTransactions.sort(key=lambda x: x.get("_createdAt", ""), reverse=True)
|
_sortBillingTransactionsBySysCreatedAtDesc(allTransactions, "getMandateTransactions")
|
||||||
return allTransactions[:limit]
|
return allTransactions[:limit]
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
@ -1385,9 +1334,8 @@ class BillingObjects:
|
||||||
try:
|
try:
|
||||||
appInterface = getAppInterface(self.currentUser)
|
appInterface = getAppInterface(self.currentUser)
|
||||||
|
|
||||||
# Get all user accounts
|
allAccounts = self.db.getRecordset(BillingAccount)
|
||||||
accountFilter = {"accountType": AccountTypeEnum.USER.value}
|
allAccounts = [acc for acc in allAccounts if acc.get("userId")]
|
||||||
allAccounts = self.db.getRecordset(BillingAccount, recordFilter=accountFilter)
|
|
||||||
|
|
||||||
# Filter by mandate if specified
|
# Filter by mandate if specified
|
||||||
if mandateIds:
|
if mandateIds:
|
||||||
|
|
@ -1549,5 +1497,5 @@ class BillingObjects:
|
||||||
logger.error(f"Error getting user transactions for mandates: {e}")
|
logger.error(f"Error getting user transactions for mandates: {e}")
|
||||||
|
|
||||||
# Sort by creation date descending and limit
|
# Sort by creation date descending and limit
|
||||||
allTransactions.sort(key=lambda x: x.get("_createdAt", ""), reverse=True)
|
_sortBillingTransactionsBySysCreatedAtDesc(allTransactions, "getUserTransactionsForMandates")
|
||||||
return allTransactions[:limit]
|
return allTransactions[:limit]
|
||||||
|
|
|
||||||
|
|
@ -251,9 +251,8 @@ class ChatObjects:
|
||||||
objectFields[fieldName] = value
|
objectFields[fieldName] = value
|
||||||
else:
|
else:
|
||||||
# Field not in model - treat as scalar if simple, otherwise filter out
|
# Field not in model - treat as scalar if simple, otherwise filter out
|
||||||
# BUT: always include metadata fields (_createdBy, _createdAt, etc.) as they're handled by connector
|
# Underscore-prefixed keys (e.g. UI meta) pass through; sys* live on PowerOnModel subclasses
|
||||||
if fieldName.startswith("_"):
|
if fieldName.startswith("_"):
|
||||||
# Metadata fields should be passed through to connector
|
|
||||||
simpleFields[fieldName] = value
|
simpleFields[fieldName] = value
|
||||||
elif isinstance(value, (str, int, float, bool, type(None))):
|
elif isinstance(value, (str, int, float, bool, type(None))):
|
||||||
simpleFields[fieldName] = value
|
simpleFields[fieldName] = value
|
||||||
|
|
@ -652,6 +651,32 @@ class ChatObjects:
|
||||||
totalPages=totalPages
|
totalPages=totalPages
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def getLastMessageTimestamp(self, workflowId: str) -> Optional[str]:
|
||||||
|
"""Return the latest publishedAt/sysCreatedAt from ChatMessage for a workflow."""
|
||||||
|
messages = self._getRecordset(ChatMessage, recordFilter={"workflowId": workflowId})
|
||||||
|
if not messages:
|
||||||
|
return None
|
||||||
|
latest = None
|
||||||
|
for msg in messages:
|
||||||
|
ts = msg.get("publishedAt") or msg.get("sysCreatedAt")
|
||||||
|
if ts and (latest is None or str(ts) > str(latest)):
|
||||||
|
latest = ts
|
||||||
|
return str(latest) if latest else None
|
||||||
|
|
||||||
|
def searchWorkflowsByContent(self, query: str, limit: int = 50) -> List[str]:
|
||||||
|
"""Return workflow IDs whose messages contain the query string (case-insensitive)."""
|
||||||
|
allMessages = self._getRecordset(ChatMessage)
|
||||||
|
matchedIds: set = set()
|
||||||
|
for msg in allMessages:
|
||||||
|
content = msg.get("message") or ""
|
||||||
|
if query in content.lower():
|
||||||
|
wfId = msg.get("workflowId")
|
||||||
|
if wfId:
|
||||||
|
matchedIds.add(wfId)
|
||||||
|
if len(matchedIds) >= limit:
|
||||||
|
break
|
||||||
|
return list(matchedIds)
|
||||||
|
|
||||||
def getWorkflow(self, workflowId: str) -> Optional[ChatWorkflow]:
|
def getWorkflow(self, workflowId: str) -> Optional[ChatWorkflow]:
|
||||||
"""Returns a workflow by ID if user has access."""
|
"""Returns a workflow by ID if user has access."""
|
||||||
# Use RBAC filtering with featureInstanceId for instance-level isolation
|
# Use RBAC filtering with featureInstanceId for instance-level isolation
|
||||||
|
|
@ -885,7 +910,7 @@ class ChatObjects:
|
||||||
"role": msg.get("role", "assistant"),
|
"role": msg.get("role", "assistant"),
|
||||||
"status": msg.get("status", "step"),
|
"status": msg.get("status", "step"),
|
||||||
"sequenceNr": msg.get("sequenceNr", 0),
|
"sequenceNr": msg.get("sequenceNr", 0),
|
||||||
"publishedAt": msg.get("publishedAt") or msg.get("_createdAt") or msg.get("timestamp") or 0,
|
"publishedAt": msg.get("publishedAt") or msg.get("sysCreatedAt") or msg.get("timestamp") or 0,
|
||||||
"success": msg.get("success"),
|
"success": msg.get("success"),
|
||||||
"actionId": msg.get("actionId"),
|
"actionId": msg.get("actionId"),
|
||||||
"actionMethod": msg.get("actionMethod"),
|
"actionMethod": msg.get("actionMethod"),
|
||||||
|
|
@ -1268,7 +1293,7 @@ class ChatObjects:
|
||||||
# CASCADE DELETE: Delete all related data first
|
# CASCADE DELETE: Delete all related data first
|
||||||
|
|
||||||
# 1. Delete message documents (but NOT the files themselves)
|
# 1. Delete message documents (but NOT the files themselves)
|
||||||
# Bypass RBAC -- workflow access already verified, child records may have different _createdBy
|
# Bypass RBAC -- workflow access already verified, child records may have different sysCreatedBy
|
||||||
existing_docs = self.db.getRecordset(ChatDocument, recordFilter={"messageId": messageId})
|
existing_docs = self.db.getRecordset(ChatDocument, recordFilter={"messageId": messageId})
|
||||||
for doc in existing_docs:
|
for doc in existing_docs:
|
||||||
self.db.recordDelete(ChatDocument, doc["id"])
|
self.db.recordDelete(ChatDocument, doc["id"])
|
||||||
|
|
@ -1296,7 +1321,7 @@ class ChatObjects:
|
||||||
|
|
||||||
|
|
||||||
# Get documents for this message from normalized table
|
# Get documents for this message from normalized table
|
||||||
# Bypass RBAC -- workflow access already verified, child records may have different _createdBy
|
# Bypass RBAC -- workflow access already verified, child records may have different sysCreatedBy
|
||||||
documents = self.db.getRecordset(ChatDocument, recordFilter={"messageId": messageId})
|
documents = self.db.getRecordset(ChatDocument, recordFilter={"messageId": messageId})
|
||||||
|
|
||||||
if not documents:
|
if not documents:
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ class KnowledgeObjects:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.currentUser: Optional[User] = None
|
self.currentUser: Optional[User] = None
|
||||||
self.userId: Optional[str] = None
|
self.userId: Optional[str] = None
|
||||||
|
self._scopeCache: Dict[str, List[str]] = {}
|
||||||
self._initializeDatabase()
|
self._initializeDatabase()
|
||||||
|
|
||||||
def _initializeDatabase(self):
|
def _initializeDatabase(self):
|
||||||
|
|
@ -51,6 +52,7 @@ class KnowledgeObjects:
|
||||||
def setUserContext(self, user: User):
|
def setUserContext(self, user: User):
|
||||||
self.currentUser = user
|
self.currentUser = user
|
||||||
self.userId = user.id if user else None
|
self.userId = user.id if user else None
|
||||||
|
self._scopeCache = {}
|
||||||
if self.userId:
|
if self.userId:
|
||||||
self.db.updateContext(self.userId)
|
self.db.updateContext(self.userId)
|
||||||
|
|
||||||
|
|
@ -89,10 +91,20 @@ class KnowledgeObjects:
|
||||||
|
|
||||||
def deleteFileContentIndex(self, fileId: str) -> bool:
|
def deleteFileContentIndex(self, fileId: str) -> bool:
|
||||||
"""Delete a FileContentIndex and all associated ContentChunks."""
|
"""Delete a FileContentIndex and all associated ContentChunks."""
|
||||||
|
existing = self.getFileContentIndex(fileId)
|
||||||
|
mandateId = (existing or {}).get("mandateId") or ""
|
||||||
chunks = self.db.getRecordset(ContentChunk, recordFilter={"fileId": fileId})
|
chunks = self.db.getRecordset(ContentChunk, recordFilter={"fileId": fileId})
|
||||||
for chunk in chunks:
|
for chunk in chunks:
|
||||||
self.db.recordDelete(ContentChunk, chunk["id"])
|
self.db.recordDelete(ContentChunk, chunk["id"])
|
||||||
return self.db.recordDelete(FileContentIndex, fileId)
|
ok = self.db.recordDelete(FileContentIndex, fileId)
|
||||||
|
if ok and mandateId:
|
||||||
|
try:
|
||||||
|
from modules.interfaces.interfaceDbBilling import _getRootInterface
|
||||||
|
|
||||||
|
_getRootInterface().reconcileMandateStorageBilling(str(mandateId))
|
||||||
|
except Exception as ex:
|
||||||
|
logger.warning("reconcileMandateStorageBilling after delete failed: %s", ex)
|
||||||
|
return ok
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# ContentChunk CRUD
|
# ContentChunk CRUD
|
||||||
|
|
@ -215,25 +227,88 @@ class KnowledgeObjects:
|
||||||
# Semantic Search
|
# Semantic Search
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
|
def _buildScopeFilter(self, userId: str = None, featureInstanceId: str = None, mandateId: str = None) -> dict:
|
||||||
|
"""Build a scope-aware filter for RAG queries.
|
||||||
|
Returns a filter dict that includes records visible to this user context."""
|
||||||
|
return {
|
||||||
|
"userId": userId,
|
||||||
|
"featureInstanceId": featureInstanceId,
|
||||||
|
"mandateId": mandateId,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _getScopedFileIds(self, userId: str = None, featureInstanceId: str = None, mandateId: str = None, isSysAdmin: bool = False) -> List[str]:
|
||||||
|
"""Collect FileContentIndex IDs visible under the scope union:
|
||||||
|
- scope=personal AND userId matches
|
||||||
|
- scope=featureInstance AND featureInstanceId matches
|
||||||
|
- scope=mandate AND mandateId matches
|
||||||
|
- scope=global (only if isSysAdmin)
|
||||||
|
"""
|
||||||
|
_cacheKey = f"{userId}:{featureInstanceId}:{mandateId}:{isSysAdmin}"
|
||||||
|
if _cacheKey in self._scopeCache:
|
||||||
|
return self._scopeCache[_cacheKey]
|
||||||
|
|
||||||
|
allIds: set = set()
|
||||||
|
|
||||||
|
if isSysAdmin:
|
||||||
|
globalIndexes = self.db.getRecordset(
|
||||||
|
FileContentIndex, recordFilter={"scope": "global"}
|
||||||
|
)
|
||||||
|
for idx in globalIndexes:
|
||||||
|
fid = idx.get("id") if isinstance(idx, dict) else getattr(idx, "id", None)
|
||||||
|
if fid:
|
||||||
|
allIds.add(fid)
|
||||||
|
|
||||||
|
if userId:
|
||||||
|
personalIndexes = self.db.getRecordset(
|
||||||
|
FileContentIndex, recordFilter={"scope": "personal", "userId": userId}
|
||||||
|
)
|
||||||
|
for idx in personalIndexes:
|
||||||
|
fid = idx.get("id") if isinstance(idx, dict) else getattr(idx, "id", None)
|
||||||
|
if fid:
|
||||||
|
allIds.add(fid)
|
||||||
|
|
||||||
|
if featureInstanceId:
|
||||||
|
instanceIndexes = self.db.getRecordset(
|
||||||
|
FileContentIndex, recordFilter={"scope": "featureInstance", "featureInstanceId": featureInstanceId}
|
||||||
|
)
|
||||||
|
for idx in instanceIndexes:
|
||||||
|
fid = idx.get("id") if isinstance(idx, dict) else getattr(idx, "id", None)
|
||||||
|
if fid:
|
||||||
|
allIds.add(fid)
|
||||||
|
|
||||||
|
if mandateId:
|
||||||
|
mandateIndexes = self.db.getRecordset(
|
||||||
|
FileContentIndex, recordFilter={"scope": "mandate", "mandateId": mandateId}
|
||||||
|
)
|
||||||
|
for idx in mandateIndexes:
|
||||||
|
fid = idx.get("id") if isinstance(idx, dict) else getattr(idx, "id", None)
|
||||||
|
if fid:
|
||||||
|
allIds.add(fid)
|
||||||
|
|
||||||
|
self._scopeCache[_cacheKey] = list(allIds)
|
||||||
|
return self._scopeCache[_cacheKey]
|
||||||
|
|
||||||
def semanticSearch(
|
def semanticSearch(
|
||||||
self,
|
self,
|
||||||
queryVector: List[float],
|
queryVector: List[float],
|
||||||
userId: str = None,
|
userId: str = None,
|
||||||
featureInstanceId: str = None,
|
featureInstanceId: str = None,
|
||||||
mandateId: str = None,
|
mandateId: str = None,
|
||||||
isShared: bool = None,
|
scope: str = None,
|
||||||
limit: int = 10,
|
limit: int = 10,
|
||||||
minScore: float = None,
|
minScore: float = None,
|
||||||
contentType: str = None,
|
contentType: str = None,
|
||||||
|
isSysAdmin: bool = False,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""Semantic search across ContentChunks using pgvector cosine similarity.
|
"""Semantic search across ContentChunks using pgvector cosine similarity.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
queryVector: Query embedding vector.
|
queryVector: Query embedding vector.
|
||||||
userId: Filter by user (Instance Layer).
|
userId: Filter by user (personal scope).
|
||||||
featureInstanceId: Filter by feature instance.
|
featureInstanceId: Filter by feature instance.
|
||||||
mandateId: Filter by mandate (for Shared Layer lookups).
|
mandateId: Filter by mandate (scope=mandate means visible to all mandate users).
|
||||||
isShared: If True, search Shared Layer via FileContentIndex join.
|
scope: If provided, filter by this specific scope value.
|
||||||
|
If not provided, use scope-union approach (personal + featureInstance + mandate + global).
|
||||||
limit: Max results.
|
limit: Max results.
|
||||||
minScore: Minimum cosine similarity (0.0 - 1.0).
|
minScore: Minimum cosine similarity (0.0 - 1.0).
|
||||||
contentType: Filter by content type (text, image, etc.).
|
contentType: Filter by content type (text, image, etc.).
|
||||||
|
|
@ -242,25 +317,36 @@ class KnowledgeObjects:
|
||||||
List of ContentChunk records with _score field, sorted by relevance.
|
List of ContentChunk records with _score field, sorted by relevance.
|
||||||
"""
|
"""
|
||||||
recordFilter = {}
|
recordFilter = {}
|
||||||
if userId:
|
|
||||||
recordFilter["userId"] = userId
|
|
||||||
if featureInstanceId:
|
|
||||||
recordFilter["featureInstanceId"] = featureInstanceId
|
|
||||||
if contentType:
|
if contentType:
|
||||||
recordFilter["contentType"] = contentType
|
recordFilter["contentType"] = contentType
|
||||||
|
|
||||||
if isShared and mandateId:
|
if scope:
|
||||||
sharedIndexes = self.db.getRecordset(
|
scopeFilter: Dict[str, Any] = {"scope": scope}
|
||||||
FileContentIndex,
|
if mandateId:
|
||||||
recordFilter={"mandateId": mandateId, "isShared": True},
|
scopeFilter["mandateId"] = mandateId
|
||||||
|
if featureInstanceId:
|
||||||
|
scopeFilter["featureInstanceId"] = featureInstanceId
|
||||||
|
scopedFileIds = self.db.getRecordset(
|
||||||
|
FileContentIndex, recordFilter=scopeFilter
|
||||||
)
|
)
|
||||||
sharedFileIds = [idx.get("id") if isinstance(idx, dict) else getattr(idx, "id", None) for idx in sharedIndexes]
|
fileIds = [
|
||||||
sharedFileIds = [fid for fid in sharedFileIds if fid]
|
idx.get("id") if isinstance(idx, dict) else getattr(idx, "id", None)
|
||||||
if not sharedFileIds:
|
for idx in scopedFileIds
|
||||||
|
]
|
||||||
|
fileIds = [fid for fid in fileIds if fid]
|
||||||
|
if not fileIds:
|
||||||
return []
|
return []
|
||||||
recordFilter.pop("userId", None)
|
recordFilter["fileId"] = fileIds
|
||||||
recordFilter.pop("featureInstanceId", None)
|
elif userId or featureInstanceId or mandateId:
|
||||||
recordFilter["fileId"] = sharedFileIds
|
scopedFileIds = self._getScopedFileIds(
|
||||||
|
userId=userId,
|
||||||
|
featureInstanceId=featureInstanceId,
|
||||||
|
mandateId=mandateId,
|
||||||
|
isSysAdmin=isSysAdmin,
|
||||||
|
)
|
||||||
|
if not scopedFileIds:
|
||||||
|
return []
|
||||||
|
recordFilter["fileId"] = scopedFileIds
|
||||||
|
|
||||||
return self.db.semanticSearch(
|
return self.db.semanticSearch(
|
||||||
modelClass=ContentChunk,
|
modelClass=ContentChunk,
|
||||||
|
|
@ -317,7 +403,7 @@ class KnowledgeObjects:
|
||||||
if mandateId:
|
if mandateId:
|
||||||
files_shared = self.db.getRecordset(
|
files_shared = self.db.getRecordset(
|
||||||
FileContentIndex,
|
FileContentIndex,
|
||||||
recordFilter={"mandateId": mandateId, "isShared": True},
|
recordFilter={"mandateId": mandateId, "scope": "mandate"},
|
||||||
)
|
)
|
||||||
|
|
||||||
by_id: Dict[str, Dict[str, Any]] = {}
|
by_id: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
@ -466,6 +552,76 @@ class KnowledgeObjects:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def aggregateMandateRagTotalBytes(mandateId: str) -> int:
|
||||||
|
"""Sum FileContentIndex.totalSize for a mandate.
|
||||||
|
|
||||||
|
Primary strategy (relies on correct scope fields on FileContentIndex):
|
||||||
|
1. FileContentIndex rows with mandateId on the index
|
||||||
|
2. FileContentIndex rows with featureInstanceId of any mandate FeatureInstance
|
||||||
|
Deduplicates by id.
|
||||||
|
"""
|
||||||
|
if not mandateId:
|
||||||
|
return 0
|
||||||
|
from modules.datamodels.datamodelFeatures import FeatureInstance
|
||||||
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
|
|
||||||
|
knowDb = getInterface(None).db
|
||||||
|
appDb = getRootInterface().db
|
||||||
|
byId: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
for row in knowDb.getRecordset(FileContentIndex, recordFilter={"mandateId": mandateId}):
|
||||||
|
rid = row.get("id")
|
||||||
|
if rid:
|
||||||
|
byId[str(rid)] = row
|
||||||
|
|
||||||
|
instances = appDb.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId})
|
||||||
|
instIds = [str(inst.get("id", "")) for inst in instances if inst.get("id")]
|
||||||
|
|
||||||
|
for instId in instIds:
|
||||||
|
for row in knowDb.getRecordset(FileContentIndex, recordFilter={"featureInstanceId": instId}):
|
||||||
|
rid = row.get("id")
|
||||||
|
if rid and str(rid) not in byId:
|
||||||
|
byId[str(rid)] = row
|
||||||
|
|
||||||
|
# DEPRECATED: file-ID-correlation fallback from poweron_management.
|
||||||
|
# Only needed for pre-migration data where mandateId/featureInstanceId on the
|
||||||
|
# FileContentIndex are empty. Remove once migrateRagScopeFields has been run.
|
||||||
|
_fallbackCount = 0
|
||||||
|
try:
|
||||||
|
from modules.datamodels.datamodelFiles import FileItem
|
||||||
|
from modules.interfaces.interfaceDbManagement import ComponentObjects
|
||||||
|
mgmtDb = ComponentObjects().db
|
||||||
|
knowledgeIf = getInterface(None)
|
||||||
|
|
||||||
|
fileIds: set = set()
|
||||||
|
for f in mgmtDb.getRecordset(FileItem, recordFilter={"mandateId": mandateId}):
|
||||||
|
fid = f.get("id") if isinstance(f, dict) else getattr(f, "id", None)
|
||||||
|
if fid:
|
||||||
|
fileIds.add(str(fid))
|
||||||
|
for instId in instIds:
|
||||||
|
for f in mgmtDb.getRecordset(FileItem, recordFilter={"featureInstanceId": instId}):
|
||||||
|
fid = f.get("id") if isinstance(f, dict) else getattr(f, "id", None)
|
||||||
|
if fid:
|
||||||
|
fileIds.add(str(fid))
|
||||||
|
|
||||||
|
for fid in fileIds:
|
||||||
|
if fid in byId:
|
||||||
|
continue
|
||||||
|
row = knowledgeIf.getFileContentIndex(fid)
|
||||||
|
if row:
|
||||||
|
byId[fid] = row
|
||||||
|
_fallbackCount += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("aggregateMandateRagTotalBytes fallback failed: %s", e)
|
||||||
|
|
||||||
|
total = sum(int(r.get("totalSize") or 0) for r in byId.values())
|
||||||
|
logger.info(
|
||||||
|
"aggregateMandateRagTotalBytes(%s): %d indexes, %d bytes (fallback: %d)",
|
||||||
|
mandateId, len(byId), total, _fallbackCount,
|
||||||
|
)
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
def getInterface(currentUser: Optional[User] = None) -> KnowledgeObjects:
|
def getInterface(currentUser: Optional[User] = None) -> KnowledgeObjects:
|
||||||
"""Get or create a KnowledgeObjects singleton."""
|
"""Get or create a KnowledgeObjects singleton."""
|
||||||
if "default" not in _instances:
|
if "default" not in _instances:
|
||||||
|
|
|
||||||
|
|
@ -175,12 +175,7 @@ class ComponentObjects:
|
||||||
# Complex objects that should be filtered out
|
# Complex objects that should be filtered out
|
||||||
objectFields[fieldName] = value
|
objectFields[fieldName] = value
|
||||||
else:
|
else:
|
||||||
# Field not in model - treat as scalar if simple, otherwise filter out
|
if isinstance(value, (str, int, float, bool, type(None))):
|
||||||
# BUT: always include metadata fields (_createdBy, _createdAt, etc.) as they're handled by connector
|
|
||||||
if fieldName.startswith("_"):
|
|
||||||
# Metadata fields should be passed through to connector
|
|
||||||
simpleFields[fieldName] = value
|
|
||||||
elif isinstance(value, (str, int, float, bool, type(None))):
|
|
||||||
simpleFields[fieldName] = value
|
simpleFields[fieldName] = value
|
||||||
else:
|
else:
|
||||||
objectFields[fieldName] = value
|
objectFields[fieldName] = value
|
||||||
|
|
@ -609,7 +604,7 @@ class ComponentObjects:
|
||||||
"""
|
"""
|
||||||
isSysAdmin = self._isSysAdmin()
|
isSysAdmin = self._isSysAdmin()
|
||||||
for prompt in prompts:
|
for prompt in prompts:
|
||||||
isOwner = prompt.get("_createdBy") == self.userId
|
isOwner = prompt.get("sysCreatedBy") == self.userId
|
||||||
prompt["_permissions"] = {
|
prompt["_permissions"] = {
|
||||||
"canUpdate": isOwner or isSysAdmin,
|
"canUpdate": isOwner or isSysAdmin,
|
||||||
"canDelete": isOwner or isSysAdmin
|
"canDelete": isOwner or isSysAdmin
|
||||||
|
|
@ -621,13 +616,13 @@ class ComponentObjects:
|
||||||
|
|
||||||
Visibility rules:
|
Visibility rules:
|
||||||
- SysAdmin: ALL prompts
|
- SysAdmin: ALL prompts
|
||||||
- Regular user: own prompts (_createdBy) + system prompts (isSystem=True)
|
- Regular user: own prompts (sysCreatedBy) + system prompts (isSystem=True)
|
||||||
"""
|
"""
|
||||||
if self._isSysAdmin():
|
if self._isSysAdmin():
|
||||||
return self.db.getRecordset(Prompt)
|
return self.db.getRecordset(Prompt)
|
||||||
|
|
||||||
# Get own prompts
|
# Get own prompts
|
||||||
ownPrompts = self.db.getRecordset(Prompt, recordFilter={"_createdBy": self.userId})
|
ownPrompts = self.db.getRecordset(Prompt, recordFilter={"sysCreatedBy": self.userId})
|
||||||
|
|
||||||
# Get system prompts
|
# Get system prompts
|
||||||
systemPrompts = self.db.getRecordset(Prompt, recordFilter={"isSystem": True})
|
systemPrompts = self.db.getRecordset(Prompt, recordFilter={"isSystem": True})
|
||||||
|
|
@ -716,7 +711,7 @@ class ComponentObjects:
|
||||||
|
|
||||||
# Visibility check for non-SysAdmin: must be owner or system prompt
|
# Visibility check for non-SysAdmin: must be owner or system prompt
|
||||||
if not self._isSysAdmin():
|
if not self._isSysAdmin():
|
||||||
isOwner = prompt.get("_createdBy") == self.userId
|
isOwner = prompt.get("sysCreatedBy") == self.userId
|
||||||
isSystem = prompt.get("isSystem", False)
|
isSystem = prompt.get("isSystem", False)
|
||||||
if not isOwner and not isSystem:
|
if not isOwner and not isSystem:
|
||||||
return None
|
return None
|
||||||
|
|
@ -747,7 +742,7 @@ class ComponentObjects:
|
||||||
raise ValueError(f"Prompt {promptId} not found")
|
raise ValueError(f"Prompt {promptId} not found")
|
||||||
|
|
||||||
# Permission check: owner or SysAdmin
|
# Permission check: owner or SysAdmin
|
||||||
isOwner = (getattr(prompt, '_createdBy', None) == self.userId)
|
isOwner = (getattr(prompt, 'sysCreatedBy', None) == self.userId)
|
||||||
if not self._isSysAdmin() and not isOwner:
|
if not self._isSysAdmin() and not isOwner:
|
||||||
raise PermissionError(f"No permission to update prompt {promptId}")
|
raise PermissionError(f"No permission to update prompt {promptId}")
|
||||||
|
|
||||||
|
|
@ -784,7 +779,7 @@ class ComponentObjects:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Permission check: owner or SysAdmin
|
# Permission check: owner or SysAdmin
|
||||||
isOwner = (getattr(prompt, '_createdBy', None) == self.userId)
|
isOwner = (getattr(prompt, 'sysCreatedBy', None) == self.userId)
|
||||||
if not self._isSysAdmin() and not isOwner:
|
if not self._isSysAdmin() and not isOwner:
|
||||||
raise PermissionError(f"No permission to delete prompt {promptId}")
|
raise PermissionError(f"No permission to delete prompt {promptId}")
|
||||||
|
|
||||||
|
|
@ -798,7 +793,7 @@ class ComponentObjects:
|
||||||
def checkForDuplicateFile(self, fileHash: str, fileName: str) -> Optional[FileItem]:
|
def checkForDuplicateFile(self, fileHash: str, fileName: str) -> Optional[FileItem]:
|
||||||
"""Checks if a file with the same hash AND fileName already exists for the current user.
|
"""Checks if a file with the same hash AND fileName already exists for the current user.
|
||||||
|
|
||||||
Duplicate = same user (_createdBy) + same fileHash + same fileName.
|
Duplicate = same user (sysCreatedBy) + same fileHash + same fileName.
|
||||||
Same hash with different name is allowed (intentional copy by user).
|
Same hash with different name is allowed (intentional copy by user).
|
||||||
Uses direct DB query (not RBAC) because files are isolated per user.
|
Uses direct DB query (not RBAC) because files are isolated per user.
|
||||||
"""
|
"""
|
||||||
|
|
@ -809,7 +804,7 @@ class ComponentObjects:
|
||||||
matchingFiles = self.db.getRecordset(
|
matchingFiles = self.db.getRecordset(
|
||||||
FileItem,
|
FileItem,
|
||||||
recordFilter={
|
recordFilter={
|
||||||
"_createdBy": self.userId,
|
"sysCreatedBy": self.userId,
|
||||||
"fileHash": fileHash,
|
"fileHash": fileHash,
|
||||||
"fileName": fileName
|
"fileName": fileName
|
||||||
}
|
}
|
||||||
|
|
@ -828,7 +823,7 @@ class ComponentObjects:
|
||||||
mimeType=file["mimeType"],
|
mimeType=file["mimeType"],
|
||||||
fileHash=file["fileHash"],
|
fileHash=file["fileHash"],
|
||||||
fileSize=file["fileSize"],
|
fileSize=file["fileSize"],
|
||||||
creationDate=file["creationDate"]
|
sysCreatedAt=file.get("sysCreatedAt") or file.get("creationDate"),
|
||||||
)
|
)
|
||||||
|
|
||||||
def getMimeType(self, fileName: str) -> str:
|
def getMimeType(self, fileName: str) -> str:
|
||||||
|
|
@ -908,7 +903,7 @@ class ComponentObjects:
|
||||||
def _getFilesByCurrentUser(self, recordFilter: Dict[str, Any] = None) -> List[Dict[str, Any]]:
|
def _getFilesByCurrentUser(self, recordFilter: Dict[str, Any] = None) -> List[Dict[str, Any]]:
|
||||||
"""Files are always user-scoped. Returns only files owned by the current user,
|
"""Files are always user-scoped. Returns only files owned by the current user,
|
||||||
regardless of role (including SysAdmin). This bypasses RBAC intentionally."""
|
regardless of role (including SysAdmin). This bypasses RBAC intentionally."""
|
||||||
filterDict = {"_createdBy": self.userId}
|
filterDict = {"sysCreatedBy": self.userId}
|
||||||
if recordFilter:
|
if recordFilter:
|
||||||
filterDict.update(recordFilter)
|
filterDict.update(recordFilter)
|
||||||
return self.db.getRecordset(FileItem, recordFilter=filterDict)
|
return self.db.getRecordset(FileItem, recordFilter=filterDict)
|
||||||
|
|
@ -927,20 +922,27 @@ class ComponentObjects:
|
||||||
If pagination is provided: PaginatedResult with items and metadata
|
If pagination is provided: PaginatedResult with items and metadata
|
||||||
"""
|
"""
|
||||||
# User-scoping filter: every user only sees their own files (bypasses RBAC SysAdmin override)
|
# User-scoping filter: every user only sees their own files (bypasses RBAC SysAdmin override)
|
||||||
recordFilter = {"_createdBy": self.userId}
|
recordFilter = {"sysCreatedBy": self.userId}
|
||||||
|
|
||||||
def _convertFileItems(files):
|
def _convertFileItems(files):
|
||||||
fileItems = []
|
fileItems = []
|
||||||
for file in files:
|
for file in files:
|
||||||
try:
|
try:
|
||||||
creationDate = file.get("creationDate")
|
sysCreatedAt = file.get("sysCreatedAt") or file.get("creationDate")
|
||||||
if creationDate is None or not isinstance(creationDate, (int, float)) or creationDate <= 0:
|
if sysCreatedAt is None or not isinstance(sysCreatedAt, (int, float)) or sysCreatedAt <= 0:
|
||||||
file["creationDate"] = getUtcTimestamp()
|
file["sysCreatedAt"] = getUtcTimestamp()
|
||||||
|
else:
|
||||||
|
file["sysCreatedAt"] = sysCreatedAt
|
||||||
|
|
||||||
fileName = file.get("fileName")
|
fileName = file.get("fileName")
|
||||||
if not fileName or fileName == "None":
|
if not fileName or fileName == "None":
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if file.get("scope") is None:
|
||||||
|
file["scope"] = "personal"
|
||||||
|
if file.get("neutralize") is None:
|
||||||
|
file["neutralize"] = False
|
||||||
|
|
||||||
fileItem = FileItem(**file)
|
fileItem = FileItem(**file)
|
||||||
fileItems.append(fileItem)
|
fileItems.append(fileItem)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -969,7 +971,7 @@ class ComponentObjects:
|
||||||
|
|
||||||
def getFile(self, fileId: str) -> Optional[FileItem]:
|
def getFile(self, fileId: str) -> Optional[FileItem]:
|
||||||
"""Returns a file by ID if it belongs to the current user (user-scoped)."""
|
"""Returns a file by ID if it belongs to the current user (user-scoped)."""
|
||||||
# Files are always user-scoped: filter by _createdBy (bypasses RBAC SysAdmin override)
|
# Files are always user-scoped: filter by sysCreatedBy (bypasses RBAC SysAdmin override)
|
||||||
filteredFiles = self._getFilesByCurrentUser(recordFilter={"id": fileId})
|
filteredFiles = self._getFilesByCurrentUser(recordFilter={"id": fileId})
|
||||||
|
|
||||||
if not filteredFiles:
|
if not filteredFiles:
|
||||||
|
|
@ -977,20 +979,19 @@ class ComponentObjects:
|
||||||
|
|
||||||
file = filteredFiles[0]
|
file = filteredFiles[0]
|
||||||
try:
|
try:
|
||||||
# Get creation date from record or use current time
|
sysCreatedAt = file.get("sysCreatedAt") or file.get("creationDate")
|
||||||
creationDate = file.get("creationDate")
|
if not sysCreatedAt:
|
||||||
if not creationDate:
|
sysCreatedAt = getUtcTimestamp()
|
||||||
creationDate = getUtcTimestamp()
|
|
||||||
|
|
||||||
return FileItem(
|
return FileItem(
|
||||||
id=file.get("id"),
|
id=file.get("id"),
|
||||||
mandateId=file.get("mandateId"),
|
mandateId=file.get("mandateId"),
|
||||||
|
featureInstanceId=file.get("featureInstanceId", ""),
|
||||||
fileName=file.get("fileName"),
|
fileName=file.get("fileName"),
|
||||||
mimeType=file.get("mimeType"),
|
mimeType=file.get("mimeType"),
|
||||||
workflowId=file.get("workflowId"),
|
|
||||||
fileHash=file.get("fileHash"),
|
fileHash=file.get("fileHash"),
|
||||||
fileSize=file.get("fileSize"),
|
fileSize=file.get("fileSize"),
|
||||||
creationDate=creationDate
|
sysCreatedAt=sysCreatedAt,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error converting file record: {str(e)}")
|
logger.error(f"Error converting file record: {str(e)}")
|
||||||
|
|
@ -1053,15 +1054,20 @@ class ComponentObjects:
|
||||||
# Ensure fileName is unique
|
# Ensure fileName is unique
|
||||||
uniqueName = self._generateUniquefileName(name)
|
uniqueName = self._generateUniquefileName(name)
|
||||||
|
|
||||||
# Use mandateId and featureInstanceId from context for proper data isolation
|
|
||||||
# Convert None to empty string to satisfy Pydantic validation
|
|
||||||
mandateId = self.mandateId or ""
|
mandateId = self.mandateId or ""
|
||||||
featureInstanceId = self.featureInstanceId or ""
|
featureInstanceId = self.featureInstanceId or ""
|
||||||
|
|
||||||
# Create FileItem instance
|
if featureInstanceId:
|
||||||
|
scope = "featureInstance"
|
||||||
|
elif mandateId:
|
||||||
|
scope = "mandate"
|
||||||
|
else:
|
||||||
|
scope = "personal"
|
||||||
|
|
||||||
fileItem = FileItem(
|
fileItem = FileItem(
|
||||||
mandateId=mandateId,
|
mandateId=mandateId,
|
||||||
featureInstanceId=featureInstanceId,
|
featureInstanceId=featureInstanceId,
|
||||||
|
scope=scope,
|
||||||
fileName=uniqueName,
|
fileName=uniqueName,
|
||||||
mimeType=mimeType,
|
mimeType=mimeType,
|
||||||
fileSize=fileSize,
|
fileSize=fileSize,
|
||||||
|
|
@ -1146,7 +1152,7 @@ class ComponentObjects:
|
||||||
self.db._ensure_connection()
|
self.db._ensure_connection()
|
||||||
with self.db.connection.cursor() as cursor:
|
with self.db.connection.cursor() as cursor:
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
'SELECT "id" FROM "FileItem" WHERE "id" = ANY(%s) AND "_createdBy" = %s',
|
'SELECT "id" FROM "FileItem" WHERE "id" = ANY(%s) AND "sysCreatedBy" = %s',
|
||||||
(uniqueIds, self.userId or ""),
|
(uniqueIds, self.userId or ""),
|
||||||
)
|
)
|
||||||
accessibleIds = [row["id"] for row in cursor.fetchall()]
|
accessibleIds = [row["id"] for row in cursor.fetchall()]
|
||||||
|
|
@ -1157,7 +1163,7 @@ class ComponentObjects:
|
||||||
|
|
||||||
cursor.execute('DELETE FROM "FileData" WHERE "id" = ANY(%s)', (accessibleIds,))
|
cursor.execute('DELETE FROM "FileData" WHERE "id" = ANY(%s)', (accessibleIds,))
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
'DELETE FROM "FileItem" WHERE "id" = ANY(%s) AND "_createdBy" = %s',
|
'DELETE FROM "FileItem" WHERE "id" = ANY(%s) AND "sysCreatedBy" = %s',
|
||||||
(accessibleIds, self.userId or ""),
|
(accessibleIds, self.userId or ""),
|
||||||
)
|
)
|
||||||
deletedFiles = cursor.rowcount
|
deletedFiles = cursor.rowcount
|
||||||
|
|
@ -1202,12 +1208,12 @@ class ComponentObjects:
|
||||||
|
|
||||||
def getFolder(self, folderId: str) -> Optional[Dict[str, Any]]:
|
def getFolder(self, folderId: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Returns a folder by ID if it belongs to the current user."""
|
"""Returns a folder by ID if it belongs to the current user."""
|
||||||
folders = self.db.getRecordset(FileFolder, recordFilter={"id": folderId, "_createdBy": self.userId or ""})
|
folders = self.db.getRecordset(FileFolder, recordFilter={"id": folderId, "sysCreatedBy": self.userId or ""})
|
||||||
return folders[0] if folders else None
|
return folders[0] if folders else None
|
||||||
|
|
||||||
def listFolders(self, parentId: Optional[str] = None) -> List[Dict[str, Any]]:
|
def listFolders(self, parentId: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||||
"""List folders for current user, optionally filtered by parentId."""
|
"""List folders for current user, optionally filtered by parentId."""
|
||||||
recordFilter = {"_createdBy": self.userId or ""}
|
recordFilter = {"sysCreatedBy": self.userId or ""}
|
||||||
if parentId is not None:
|
if parentId is not None:
|
||||||
recordFilter["parentId"] = parentId
|
recordFilter["parentId"] = parentId
|
||||||
return self.db.getRecordset(FileFolder, recordFilter=recordFilter)
|
return self.db.getRecordset(FileFolder, recordFilter=recordFilter)
|
||||||
|
|
@ -1256,7 +1262,7 @@ class ComponentObjects:
|
||||||
self.db._ensure_connection()
|
self.db._ensure_connection()
|
||||||
with self.db.connection.cursor() as cursor:
|
with self.db.connection.cursor() as cursor:
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
'SELECT "id" FROM "FileItem" WHERE "id" = ANY(%s) AND "_createdBy" = %s',
|
'SELECT "id" FROM "FileItem" WHERE "id" = ANY(%s) AND "sysCreatedBy" = %s',
|
||||||
(uniqueIds, self.userId or ""),
|
(uniqueIds, self.userId or ""),
|
||||||
)
|
)
|
||||||
accessibleIds = [row["id"] for row in cursor.fetchall()]
|
accessibleIds = [row["id"] for row in cursor.fetchall()]
|
||||||
|
|
@ -1265,8 +1271,8 @@ class ComponentObjects:
|
||||||
raise FileNotFoundError(f"Files not found or not accessible: {missingIds}")
|
raise FileNotFoundError(f"Files not found or not accessible: {missingIds}")
|
||||||
|
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
'UPDATE "FileItem" SET "folderId" = %s, "_modifiedAt" = %s, "_modifiedBy" = %s '
|
'UPDATE "FileItem" SET "folderId" = %s, "sysModifiedAt" = %s, "sysModifiedBy" = %s '
|
||||||
'WHERE "id" = ANY(%s) AND "_createdBy" = %s',
|
'WHERE "id" = ANY(%s) AND "sysCreatedBy" = %s',
|
||||||
(targetFolderId, getUtcTimestamp(), self.userId or "", accessibleIds, self.userId or ""),
|
(targetFolderId, getUtcTimestamp(), self.userId or "", accessibleIds, self.userId or ""),
|
||||||
)
|
)
|
||||||
movedFiles = cursor.rowcount
|
movedFiles = cursor.rowcount
|
||||||
|
|
@ -1295,7 +1301,7 @@ class ComponentObjects:
|
||||||
|
|
||||||
existingInTarget = self.db.getRecordset(
|
existingInTarget = self.db.getRecordset(
|
||||||
FileFolder,
|
FileFolder,
|
||||||
recordFilter={"parentId": targetParentId or "", "_createdBy": self.userId or ""},
|
recordFilter={"parentId": targetParentId or "", "sysCreatedBy": self.userId or ""},
|
||||||
)
|
)
|
||||||
existingNames = {f.get("name"): f.get("id") for f in existingInTarget}
|
existingNames = {f.get("name"): f.get("id") for f in existingInTarget}
|
||||||
movingNames: Dict[str, str] = {}
|
movingNames: Dict[str, str] = {}
|
||||||
|
|
@ -1316,8 +1322,8 @@ class ComponentObjects:
|
||||||
self.db._ensure_connection()
|
self.db._ensure_connection()
|
||||||
with self.db.connection.cursor() as cursor:
|
with self.db.connection.cursor() as cursor:
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
'UPDATE "FileFolder" SET "parentId" = %s, "_modifiedAt" = %s, "_modifiedBy" = %s '
|
'UPDATE "FileFolder" SET "parentId" = %s, "sysModifiedAt" = %s, "sysModifiedBy" = %s '
|
||||||
'WHERE "id" = ANY(%s) AND "_createdBy" = %s',
|
'WHERE "id" = ANY(%s) AND "sysCreatedBy" = %s',
|
||||||
(targetParentId, getUtcTimestamp(), self.userId or "", uniqueIds, self.userId or ""),
|
(targetParentId, getUtcTimestamp(), self.userId or "", uniqueIds, self.userId or ""),
|
||||||
)
|
)
|
||||||
movedFolders = cursor.rowcount
|
movedFolders = cursor.rowcount
|
||||||
|
|
@ -1335,7 +1341,7 @@ class ComponentObjects:
|
||||||
if not folder:
|
if not folder:
|
||||||
raise FileNotFoundError(f"Folder {folderId} not found")
|
raise FileNotFoundError(f"Folder {folderId} not found")
|
||||||
|
|
||||||
childFolders = self.db.getRecordset(FileFolder, recordFilter={"parentId": folderId, "_createdBy": self.userId or ""})
|
childFolders = self.db.getRecordset(FileFolder, recordFilter={"parentId": folderId, "sysCreatedBy": self.userId or ""})
|
||||||
childFiles = self._getFilesByCurrentUser(recordFilter={"folderId": folderId})
|
childFiles = self._getFilesByCurrentUser(recordFilter={"folderId": folderId})
|
||||||
|
|
||||||
if not recursive and (childFolders or childFiles):
|
if not recursive and (childFolders or childFiles):
|
||||||
|
|
@ -1384,7 +1390,7 @@ class ComponentObjects:
|
||||||
self.db._ensure_connection()
|
self.db._ensure_connection()
|
||||||
with self.db.connection.cursor() as cursor:
|
with self.db.connection.cursor() as cursor:
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
'SELECT "id" FROM "FileFolder" WHERE "id" = ANY(%s) AND "_createdBy" = %s',
|
'SELECT "id" FROM "FileFolder" WHERE "id" = ANY(%s) AND "sysCreatedBy" = %s',
|
||||||
(uniqueIds, self.userId or ""),
|
(uniqueIds, self.userId or ""),
|
||||||
)
|
)
|
||||||
rootAccessibleIds = [row["id"] for row in cursor.fetchall()]
|
rootAccessibleIds = [row["id"] for row in cursor.fetchall()]
|
||||||
|
|
@ -1397,12 +1403,12 @@ class ComponentObjects:
|
||||||
WITH RECURSIVE folder_tree AS (
|
WITH RECURSIVE folder_tree AS (
|
||||||
SELECT "id"
|
SELECT "id"
|
||||||
FROM "FileFolder"
|
FROM "FileFolder"
|
||||||
WHERE "id" = ANY(%s) AND "_createdBy" = %s
|
WHERE "id" = ANY(%s) AND "sysCreatedBy" = %s
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT child."id"
|
SELECT child."id"
|
||||||
FROM "FileFolder" child
|
FROM "FileFolder" child
|
||||||
INNER JOIN folder_tree ft ON child."parentId" = ft."id"
|
INNER JOIN folder_tree ft ON child."parentId" = ft."id"
|
||||||
WHERE child."_createdBy" = %s
|
WHERE child."sysCreatedBy" = %s
|
||||||
)
|
)
|
||||||
SELECT DISTINCT "id" FROM folder_tree
|
SELECT DISTINCT "id" FROM folder_tree
|
||||||
""",
|
""",
|
||||||
|
|
@ -1411,7 +1417,7 @@ class ComponentObjects:
|
||||||
allFolderIds = [row["id"] for row in cursor.fetchall()]
|
allFolderIds = [row["id"] for row in cursor.fetchall()]
|
||||||
|
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
'SELECT "id" FROM "FileItem" WHERE "folderId" = ANY(%s) AND "_createdBy" = %s',
|
'SELECT "id" FROM "FileItem" WHERE "folderId" = ANY(%s) AND "sysCreatedBy" = %s',
|
||||||
(allFolderIds, self.userId or ""),
|
(allFolderIds, self.userId or ""),
|
||||||
)
|
)
|
||||||
allFileIds = [row["id"] for row in cursor.fetchall()]
|
allFileIds = [row["id"] for row in cursor.fetchall()]
|
||||||
|
|
@ -1419,7 +1425,7 @@ class ComponentObjects:
|
||||||
if allFileIds:
|
if allFileIds:
|
||||||
cursor.execute('DELETE FROM "FileData" WHERE "id" = ANY(%s)', (allFileIds,))
|
cursor.execute('DELETE FROM "FileData" WHERE "id" = ANY(%s)', (allFileIds,))
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
'DELETE FROM "FileItem" WHERE "id" = ANY(%s) AND "_createdBy" = %s',
|
'DELETE FROM "FileItem" WHERE "id" = ANY(%s) AND "sysCreatedBy" = %s',
|
||||||
(allFileIds, self.userId or ""),
|
(allFileIds, self.userId or ""),
|
||||||
)
|
)
|
||||||
deletedFiles = cursor.rowcount
|
deletedFiles = cursor.rowcount
|
||||||
|
|
@ -1427,7 +1433,7 @@ class ComponentObjects:
|
||||||
deletedFiles = 0
|
deletedFiles = 0
|
||||||
|
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
'DELETE FROM "FileFolder" WHERE "id" = ANY(%s) AND "_createdBy" = %s',
|
'DELETE FROM "FileFolder" WHERE "id" = ANY(%s) AND "sysCreatedBy" = %s',
|
||||||
(allFolderIds, self.userId or ""),
|
(allFolderIds, self.userId or ""),
|
||||||
)
|
)
|
||||||
deletedFolders = cursor.rowcount
|
deletedFolders = cursor.rowcount
|
||||||
|
|
|
||||||
|
|
@ -293,9 +293,45 @@ class SubscriptionObjects:
|
||||||
if current + delta > cap:
|
if current + delta > cap:
|
||||||
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException
|
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException
|
||||||
raise SubscriptionCapacityException(resourceType=resourceType, currentCount=current, maxAllowed=cap)
|
raise SubscriptionCapacityException(resourceType=resourceType, currentCount=current, maxAllowed=cap)
|
||||||
|
elif resourceType == "dataVolumeMB":
|
||||||
|
cap = plan.maxDataVolumeMB
|
||||||
|
if cap is None:
|
||||||
|
return True
|
||||||
|
currentMB = self.getMandateDataVolumeMB(mandateId)
|
||||||
|
if currentMB + delta > cap:
|
||||||
|
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException
|
||||||
|
raise SubscriptionCapacityException(resourceType=resourceType, currentCount=int(currentMB), maxAllowed=cap)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def getMandateDataVolumeMB(self, mandateId: str) -> float:
|
||||||
|
"""Total indexed data volume for the mandate (MB), for billing and capacity checks."""
|
||||||
|
return self._getMandateDataVolumeMB(mandateId)
|
||||||
|
|
||||||
|
def _getMandateDataVolumeMB(self, mandateId: str) -> float:
|
||||||
|
"""Sum RAG index size (FileContentIndex.totalSize) for the mandate; reads poweron_knowledge."""
|
||||||
|
try:
|
||||||
|
from modules.interfaces.interfaceDbKnowledge import aggregateMandateRagTotalBytes
|
||||||
|
|
||||||
|
return aggregateMandateRagTotalBytes(mandateId) / (1024 * 1024)
|
||||||
|
except Exception:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def getDataVolumeWarning(self, mandateId: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Return a warning dict if mandate uses >=80% of maxDataVolumeMB, else None."""
|
||||||
|
sub = self.getOperativeForMandate(mandateId)
|
||||||
|
if not sub:
|
||||||
|
return None
|
||||||
|
plan = self.getPlan(sub.get("planKey", ""))
|
||||||
|
if not plan or not plan.maxDataVolumeMB:
|
||||||
|
return None
|
||||||
|
usedMB = self.getMandateDataVolumeMB(mandateId)
|
||||||
|
limitMB = plan.maxDataVolumeMB
|
||||||
|
percent = (usedMB / limitMB * 100) if limitMB > 0 else 0
|
||||||
|
if percent >= 80:
|
||||||
|
return {"usedMB": round(usedMB, 2), "limitMB": limitMB, "percent": round(percent, 1), "warning": True}
|
||||||
|
return {"usedMB": round(usedMB, 2), "limitMB": limitMB, "percent": round(percent, 1), "warning": False}
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Counting (cross-DB queries against poweron_app)
|
# Counting (cross-DB queries against poweron_app)
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
@ -321,11 +357,18 @@ class SubscriptionObjects:
|
||||||
# Stripe quantity sync
|
# Stripe quantity sync
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
def syncQuantityToStripe(self, subscriptionId: str) -> None:
|
def syncQuantityToStripe(self, subscriptionId: str, *, raiseOnError: bool = False) -> None:
|
||||||
"""Update Stripe subscription item quantities to match actual active counts.
|
"""Update Stripe subscription item quantities to match actual active counts.
|
||||||
Takes subscriptionId, not mandateId."""
|
Takes subscriptionId, not mandateId.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
raiseOnError: If True, propagate Stripe API errors instead of logging them.
|
||||||
|
Use True for billing-critical paths (store activation).
|
||||||
|
"""
|
||||||
sub = self.getById(subscriptionId)
|
sub = self.getById(subscriptionId)
|
||||||
if not sub or not sub.get("stripeSubscriptionId"):
|
if not sub or not sub.get("stripeSubscriptionId"):
|
||||||
|
if raiseOnError:
|
||||||
|
raise ValueError(f"Subscription {subscriptionId} hat keine Stripe-Anbindung — Abrechnung nicht möglich.")
|
||||||
return
|
return
|
||||||
|
|
||||||
mandateId = sub["mandateId"]
|
mandateId = sub["mandateId"]
|
||||||
|
|
@ -351,3 +394,5 @@ class SubscriptionObjects:
|
||||||
logger.info("Stripe quantity synced for sub %s: users=%d, instances=%d", subscriptionId, activeUsers, activeInstances)
|
logger.info("Stripe quantity synced for sub %s: users=%d, instances=%d", subscriptionId, activeUsers, activeInstances)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("syncQuantityToStripe(%s) failed: %s", subscriptionId, e)
|
logger.error("syncQuantityToStripe(%s) failed: %s", subscriptionId, e)
|
||||||
|
if raiseOnError:
|
||||||
|
raise
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ class FeatureInterface:
|
||||||
records = self.db.getRecordset(Feature, recordFilter={"code": featureCode})
|
records = self.db.getRecordset(Feature, recordFilter={"code": featureCode})
|
||||||
if not records:
|
if not records:
|
||||||
return None
|
return None
|
||||||
cleanedRecord = {k: v for k, v in records[0].items() if not k.startswith("_")}
|
cleanedRecord = dict(records[0])
|
||||||
return Feature(**cleanedRecord)
|
return Feature(**cleanedRecord)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting feature {featureCode}: {e}")
|
logger.error(f"Error getting feature {featureCode}: {e}")
|
||||||
|
|
@ -74,7 +74,7 @@ class FeatureInterface:
|
||||||
records = self.db.getRecordset(Feature)
|
records = self.db.getRecordset(Feature)
|
||||||
result = []
|
result = []
|
||||||
for record in records:
|
for record in records:
|
||||||
cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
|
cleanedRecord = dict(record)
|
||||||
result.append(Feature(**cleanedRecord))
|
result.append(Feature(**cleanedRecord))
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -120,7 +120,7 @@ class FeatureInterface:
|
||||||
records = self.db.getRecordset(FeatureInstance, recordFilter={"id": instanceId})
|
records = self.db.getRecordset(FeatureInstance, recordFilter={"id": instanceId})
|
||||||
if not records:
|
if not records:
|
||||||
return None
|
return None
|
||||||
cleanedRecord = {k: v for k, v in records[0].items() if not k.startswith("_")}
|
cleanedRecord = dict(records[0])
|
||||||
return FeatureInstance(**cleanedRecord)
|
return FeatureInstance(**cleanedRecord)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting feature instance {instanceId}: {e}")
|
logger.error(f"Error getting feature instance {instanceId}: {e}")
|
||||||
|
|
@ -144,7 +144,7 @@ class FeatureInterface:
|
||||||
records = self.db.getRecordset(FeatureInstance, recordFilter=recordFilter)
|
records = self.db.getRecordset(FeatureInstance, recordFilter=recordFilter)
|
||||||
result = []
|
result = []
|
||||||
for record in records:
|
for record in records:
|
||||||
cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
|
cleanedRecord = dict(record)
|
||||||
result.append(FeatureInstance(**cleanedRecord))
|
result.append(FeatureInstance(**cleanedRecord))
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -199,7 +199,7 @@ class FeatureInterface:
|
||||||
if copyTemplateRoles:
|
if copyTemplateRoles:
|
||||||
self._copyTemplateRoles(featureCode, mandateId, instanceId)
|
self._copyTemplateRoles(featureCode, mandateId, instanceId)
|
||||||
|
|
||||||
cleanedRecord = {k: v for k, v in createdInstance.items() if not k.startswith("_")}
|
cleanedRecord = dict(createdInstance)
|
||||||
return FeatureInstance(**cleanedRecord)
|
return FeatureInstance(**cleanedRecord)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -208,7 +208,11 @@ class FeatureInterface:
|
||||||
|
|
||||||
def _copyTemplateRoles(self, featureCode: str, mandateId: str, instanceId: str) -> int:
|
def _copyTemplateRoles(self, featureCode: str, mandateId: str, instanceId: str) -> int:
|
||||||
"""
|
"""
|
||||||
Copy global template roles for a feature to a new instance.
|
Copy feature-specific template roles to a new instance.
|
||||||
|
|
||||||
|
INVARIANT: Feature instances MUST receive feature-specific roles
|
||||||
|
(e.g. workspace-admin, workspace-user). NEVER generic mandate roles.
|
||||||
|
Feature templates have featureCode set and isSystemRole=False.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
featureCode: Feature code
|
featureCode: Feature code
|
||||||
|
|
@ -217,19 +221,30 @@ class FeatureInterface:
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Number of roles copied
|
Number of roles copied
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If no feature-specific template roles exist
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Find global template roles for this feature (mandateId=None)
|
allTemplates = self.db.getRecordset(
|
||||||
globalRoles = self.db.getRecordset(
|
|
||||||
Role,
|
Role,
|
||||||
recordFilter={"featureCode": featureCode, "mandateId": None}
|
recordFilter={"featureCode": featureCode}
|
||||||
)
|
)
|
||||||
|
|
||||||
if not globalRoles:
|
featureTemplates = [
|
||||||
logger.debug(f"No template roles found for feature {featureCode}")
|
r for r in allTemplates
|
||||||
return 0
|
if r.get("mandateId") is None and r.get("featureInstanceId") is None
|
||||||
|
]
|
||||||
|
|
||||||
templateRoleIds = [r.get("id") for r in globalRoles]
|
if not featureTemplates:
|
||||||
|
raise ValueError(
|
||||||
|
f"No feature-specific template roles found for '{featureCode}'. "
|
||||||
|
f"Each feature module must define TEMPLATE_ROLES and sync them to DB on startup."
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Found {len(featureTemplates)} feature-specific template roles for '{featureCode}'")
|
||||||
|
|
||||||
|
templateRoleIds = [r.get("id") for r in featureTemplates]
|
||||||
|
|
||||||
# BULK: Load all template AccessRules in one query
|
# BULK: Load all template AccessRules in one query
|
||||||
allTemplateRules = []
|
allTemplateRules = []
|
||||||
|
|
@ -246,7 +261,7 @@ class FeatureInterface:
|
||||||
|
|
||||||
# Copy roles and their AccessRules
|
# Copy roles and their AccessRules
|
||||||
copiedCount = 0
|
copiedCount = 0
|
||||||
for templateRole in globalRoles:
|
for templateRole in featureTemplates:
|
||||||
newRoleId = str(uuid.uuid4())
|
newRoleId = str(uuid.uuid4())
|
||||||
|
|
||||||
# Create new role for this instance
|
# Create new role for this instance
|
||||||
|
|
@ -282,9 +297,11 @@ class FeatureInterface:
|
||||||
logger.info(f"Copied {copiedCount} template roles for instance {instanceId}")
|
logger.info(f"Copied {copiedCount} template roles for instance {instanceId}")
|
||||||
return copiedCount
|
return copiedCount
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error copying template roles: {e}")
|
logger.error(f"Error copying template roles: {e}")
|
||||||
return 0
|
raise ValueError(f"Failed to copy template roles for '{featureCode}': {e}")
|
||||||
|
|
||||||
def syncRolesFromTemplate(self, featureInstanceId: str, addOnly: bool = True) -> Dict[str, int]:
|
def syncRolesFromTemplate(self, featureInstanceId: str, addOnly: bool = True) -> Dict[str, int]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -309,11 +326,15 @@ class FeatureInterface:
|
||||||
featureCode = instance.featureCode
|
featureCode = instance.featureCode
|
||||||
mandateId = instance.mandateId
|
mandateId = instance.mandateId
|
||||||
|
|
||||||
# Get current template roles
|
# Get feature-specific template roles (mandateId=None, featureInstanceId=None)
|
||||||
templateRoles = self.db.getRecordset(
|
allForFeature = self.db.getRecordset(
|
||||||
Role,
|
Role,
|
||||||
recordFilter={"featureCode": featureCode, "mandateId": None}
|
recordFilter={"featureCode": featureCode}
|
||||||
)
|
)
|
||||||
|
templateRoles = [
|
||||||
|
r for r in allForFeature
|
||||||
|
if r.get("mandateId") is None and r.get("featureInstanceId") is None
|
||||||
|
]
|
||||||
templateLabels = {r.get("roleLabel") for r in templateRoles}
|
templateLabels = {r.get("roleLabel") for r in templateRoles}
|
||||||
|
|
||||||
# Get current instance roles
|
# Get current instance roles
|
||||||
|
|
@ -414,7 +435,7 @@ class FeatureInterface:
|
||||||
|
|
||||||
updated = self.db.recordModify(FeatureInstance, instanceId, filteredData)
|
updated = self.db.recordModify(FeatureInstance, instanceId, filteredData)
|
||||||
if updated:
|
if updated:
|
||||||
cleanedRecord = {k: v for k, v in updated.items() if not k.startswith("_")}
|
cleanedRecord = dict(updated)
|
||||||
return FeatureInstance(**cleanedRecord)
|
return FeatureInstance(**cleanedRecord)
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -463,7 +484,7 @@ class FeatureInterface:
|
||||||
records = self.db.getRecordset(Role, recordFilter=recordFilter)
|
records = self.db.getRecordset(Role, recordFilter=recordFilter)
|
||||||
result = []
|
result = []
|
||||||
for record in records:
|
for record in records:
|
||||||
cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
|
cleanedRecord = dict(record)
|
||||||
result.append(Role(**cleanedRecord))
|
result.append(Role(**cleanedRecord))
|
||||||
return result
|
return result
|
||||||
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); bei gesetztem featureInstanceId zusätzlich _createdBy
|
- data.chat.*, data.files.*, data.automation.*: GROUP = MY (benutzer-eigen); bei gesetztem featureInstanceId zusätzlich sysCreatedBy
|
||||||
- data.feature.*: GROUP filtert nach mandateId/featureInstanceId
|
- data.feature.*: GROUP filtert nach mandateId/featureInstanceId
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -146,7 +146,7 @@ def getRecordsetWithRBAC(
|
||||||
mandateId: Explicit mandate context (from request header). Required for GROUP access.
|
mandateId: Explicit mandate context (from request header). Required for GROUP access.
|
||||||
featureInstanceId: Explicit feature instance context
|
featureInstanceId: Explicit feature instance context
|
||||||
enrichPermissions: If True, adds _permissions field to each record with row-level
|
enrichPermissions: If True, adds _permissions field to each record with row-level
|
||||||
permissions { canUpdate, canDelete } based on RBAC rules and _createdBy
|
permissions { canUpdate, canDelete } based on RBAC rules and sysCreatedBy
|
||||||
featureCode: Optional feature code for feature-specific tables (e.g., "trustee").
|
featureCode: Optional feature code for feature-specific tables (e.g., "trustee").
|
||||||
If None, table is treated as a system table.
|
If None, table is treated as a system table.
|
||||||
|
|
||||||
|
|
@ -657,7 +657,7 @@ def buildRbacWhereClause(
|
||||||
# shared featureInstance (stale RBAC rules or merged roles). Same as MY.
|
# shared featureInstance (stale RBAC rules or merged roles). Same as MY.
|
||||||
namespaceAll = TABLE_NAMESPACE.get(table, "system")
|
namespaceAll = TABLE_NAMESPACE.get(table, "system")
|
||||||
if featureInstanceId and namespaceAll == "chat":
|
if featureInstanceId and namespaceAll == "chat":
|
||||||
userIdFieldAll = "_createdBy"
|
userIdFieldAll = "sysCreatedBy"
|
||||||
if table == "UserInDB":
|
if table == "UserInDB":
|
||||||
userIdFieldAll = "id"
|
userIdFieldAll = "id"
|
||||||
elif table == "UserConnection":
|
elif table == "UserConnection":
|
||||||
|
|
@ -671,7 +671,7 @@ def buildRbacWhereClause(
|
||||||
return {"condition": " AND ".join(baseConditions), "values": baseValues}
|
return {"condition": " AND ".join(baseConditions), "values": baseValues}
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# My records - filter by _createdBy or userId field
|
# My records - filter by sysCreatedBy or userId field
|
||||||
if readLevel == AccessLevel.MY:
|
if readLevel == AccessLevel.MY:
|
||||||
# Try common field names for creator
|
# Try common field names for creator
|
||||||
userIdField = None
|
userIdField = None
|
||||||
|
|
@ -680,7 +680,7 @@ def buildRbacWhereClause(
|
||||||
elif table == "UserConnection":
|
elif table == "UserConnection":
|
||||||
userIdField = "userId"
|
userIdField = "userId"
|
||||||
else:
|
else:
|
||||||
userIdField = "_createdBy"
|
userIdField = "sysCreatedBy"
|
||||||
|
|
||||||
conditions = list(baseConditions)
|
conditions = list(baseConditions)
|
||||||
values = list(baseValues)
|
values = list(baseValues)
|
||||||
|
|
@ -707,7 +707,7 @@ def buildRbacWhereClause(
|
||||||
if featureInstanceId and readLevel == AccessLevel.GROUP:
|
if featureInstanceId and readLevel == AccessLevel.GROUP:
|
||||||
conditions = list(baseConditions)
|
conditions = list(baseConditions)
|
||||||
values = list(baseValues)
|
values = list(baseValues)
|
||||||
conditions.append('"_createdBy" = %s')
|
conditions.append('"sysCreatedBy" = %s')
|
||||||
values.append(currentUser.id)
|
values.append(currentUser.id)
|
||||||
return {"condition": " AND ".join(conditions), "values": values}
|
return {"condition": " AND ".join(conditions), "values": values}
|
||||||
return {"condition": " AND ".join(baseConditions), "values": baseValues}
|
return {"condition": " AND ".join(baseConditions), "values": baseValues}
|
||||||
|
|
@ -829,7 +829,7 @@ def _enrichRecordsWithPermissions(
|
||||||
|
|
||||||
Logic:
|
Logic:
|
||||||
- AccessLevel.ALL ('a'): User can update/delete all records
|
- AccessLevel.ALL ('a'): User can update/delete all records
|
||||||
- AccessLevel.MY ('m'): User can only update/delete records where _createdBy == userId
|
- AccessLevel.MY ('m'): User can only update/delete records where sysCreatedBy == userId
|
||||||
- AccessLevel.GROUP ('g'): Same as MY for now (group-level ownership)
|
- AccessLevel.GROUP ('g'): Same as MY for now (group-level ownership)
|
||||||
- AccessLevel.NONE ('n'): User cannot update/delete any records
|
- AccessLevel.NONE ('n'): User cannot update/delete any records
|
||||||
|
|
||||||
|
|
@ -846,7 +846,7 @@ def _enrichRecordsWithPermissions(
|
||||||
|
|
||||||
for record in records:
|
for record in records:
|
||||||
recordCopy = dict(record)
|
recordCopy = dict(record)
|
||||||
createdBy = record.get("_createdBy")
|
createdBy = record.get("sysCreatedBy")
|
||||||
|
|
||||||
# Determine canUpdate
|
# Determine canUpdate
|
||||||
canUpdate = _checkRowPermission(permissions.update, userId, createdBy)
|
canUpdate = _checkRowPermission(permissions.update, userId, createdBy)
|
||||||
|
|
@ -873,7 +873,7 @@ def _checkRowPermission(
|
||||||
Args:
|
Args:
|
||||||
accessLevel: The permission level (ALL, MY, GROUP, NONE)
|
accessLevel: The permission level (ALL, MY, GROUP, NONE)
|
||||||
userId: Current user's ID
|
userId: Current user's ID
|
||||||
recordCreatedBy: The _createdBy value of the record
|
recordCreatedBy: The sysCreatedBy value of the record
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if user has permission, False otherwise
|
True if user has permission, False otherwise
|
||||||
|
|
@ -884,9 +884,9 @@ def _checkRowPermission(
|
||||||
if accessLevel == AccessLevel.ALL:
|
if accessLevel == AccessLevel.ALL:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# MY and GROUP: Check ownership via _createdBy
|
# MY and GROUP: Check ownership via sysCreatedBy
|
||||||
if accessLevel in (AccessLevel.MY, AccessLevel.GROUP):
|
if accessLevel in (AccessLevel.MY, AccessLevel.GROUP):
|
||||||
# If record has no _createdBy, allow access (can't verify ownership)
|
# If record has no sysCreatedBy, allow access (can't verify ownership)
|
||||||
if not recordCreatedBy:
|
if not recordCreatedBy:
|
||||||
return True
|
return True
|
||||||
# If no userId, can't verify - deny
|
# If no userId, can't verify - deny
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,7 @@ import logging
|
||||||
from typing import AsyncGenerator, Callable, Dict, Any, Optional, List
|
from typing import AsyncGenerator, Callable, Dict, Any, Optional, List
|
||||||
|
|
||||||
from modules.connectors.connectorVoiceGoogle import ConnectorGoogleSpeech
|
from modules.connectors.connectorVoiceGoogle import ConnectorGoogleSpeech
|
||||||
from modules.datamodels.datamodelVoice import VoiceSettings
|
|
||||||
from modules.datamodels.datamodelUam import User
|
from modules.datamodels.datamodelUam import User
|
||||||
from modules.shared.timeUtils import getUtcTimestamp
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -335,123 +333,6 @@ class VoiceObjects:
|
||||||
"error": str(e)
|
"error": str(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
# Voice Settings Management
|
|
||||||
|
|
||||||
def getVoiceSettings(self, userId: str) -> Optional[VoiceSettings]:
|
|
||||||
"""
|
|
||||||
Get voice settings for a user.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
userId: User ID to get settings for
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
VoiceSettings object or None if not found
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# This would typically query the database
|
|
||||||
# For now, return None as this is handled by the database interface
|
|
||||||
logger.debug(f"Getting voice settings for user: {userId}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ Error getting voice settings: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def createVoiceSettings(self, settingsData: Dict[str, Any]) -> Optional[VoiceSettings]:
|
|
||||||
"""
|
|
||||||
Create new voice settings.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
settingsData: Dictionary containing voice settings data
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Created VoiceSettings object or None if failed
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
logger.info(f"Creating voice settings: {settingsData}")
|
|
||||||
|
|
||||||
# Ensure mandateId is set from context if not provided
|
|
||||||
if "mandateId" not in settingsData or not settingsData["mandateId"]:
|
|
||||||
if not self.mandateId:
|
|
||||||
raise ValueError("mandateId is required but not provided and context has no mandateId")
|
|
||||||
settingsData["mandateId"] = self.mandateId
|
|
||||||
|
|
||||||
# Add timestamps
|
|
||||||
currentTime = getUtcTimestamp()
|
|
||||||
settingsData["creationDate"] = currentTime
|
|
||||||
settingsData["lastModified"] = currentTime
|
|
||||||
|
|
||||||
# Create VoiceSettings object
|
|
||||||
voiceSettings = VoiceSettings(**settingsData)
|
|
||||||
|
|
||||||
logger.info(f"✅ Voice settings created: {voiceSettings.id}")
|
|
||||||
return voiceSettings
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ Error creating voice settings: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def updateVoiceSettings(self, userId: str, settingsData: Dict[str, Any]) -> Optional[VoiceSettings]:
|
|
||||||
"""
|
|
||||||
Update existing voice settings.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
userId: User ID to update settings for
|
|
||||||
settingsData: Dictionary containing updated voice settings data
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Updated VoiceSettings object or None if failed
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
logger.info(f"Updating voice settings for user {userId}: {settingsData}")
|
|
||||||
|
|
||||||
# Add last modified timestamp
|
|
||||||
settingsData["lastModified"] = getUtcTimestamp()
|
|
||||||
|
|
||||||
# Create updated VoiceSettings object
|
|
||||||
voiceSettings = VoiceSettings(**settingsData)
|
|
||||||
|
|
||||||
logger.info(f"✅ Voice settings updated: {voiceSettings.id}")
|
|
||||||
return voiceSettings
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ Error updating voice settings: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def getOrCreateVoiceSettings(self, userId: str) -> Optional[VoiceSettings]:
|
|
||||||
"""
|
|
||||||
Get existing voice settings or create default ones.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
userId: User ID to get/create settings for
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
VoiceSettings object
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Try to get existing settings
|
|
||||||
existingSettings = self.getVoiceSettings(userId)
|
|
||||||
|
|
||||||
if existingSettings:
|
|
||||||
return existingSettings
|
|
||||||
|
|
||||||
# Create default settings if none exist
|
|
||||||
defaultSettings = {
|
|
||||||
"userId": userId,
|
|
||||||
"mandateId": self.mandateId,
|
|
||||||
"sttLanguage": "de-DE",
|
|
||||||
"ttsLanguage": "de-DE",
|
|
||||||
"ttsVoice": "de-DE-Wavenet-A",
|
|
||||||
"translationEnabled": True,
|
|
||||||
"targetLanguage": "en-US"
|
|
||||||
}
|
|
||||||
|
|
||||||
return self.createVoiceSettings(defaultSettings)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ Error getting or creating voice settings: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Language and Voice Information
|
# Language and Voice Information
|
||||||
|
|
||||||
async def getAvailableLanguages(self) -> Dict[str, Any]:
|
async def getAvailableLanguages(self) -> Dict[str, Any]:
|
||||||
|
|
|
||||||
1
modules/migration/__init__.py
Normal file
1
modules/migration/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
# Migration modules
|
||||||
114
modules/migration/migrateRagScopeFields.py
Normal file
114
modules/migration/migrateRagScopeFields.py
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""
|
||||||
|
Migration: Backfill FileContentIndex scope fields from FileItem (Single Source of Truth).
|
||||||
|
|
||||||
|
Fixes legacy rows in poweron_knowledge where scope/mandateId/featureInstanceId
|
||||||
|
are empty or default ("personal") despite the corresponding FileItem having correct values.
|
||||||
|
|
||||||
|
Idempotent — safe to run multiple times. Uses a DB flag to skip if already completed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from modules.shared.configuration import APP_CONFIG
|
||||||
|
from modules.connectors.connectorDbPostgre import _get_cached_connector
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_MIGRATION_FLAG_KEY = "migration_rag_scope_fields_completed"
|
||||||
|
|
||||||
|
|
||||||
|
def _isMigrationCompleted(appDb) -> bool:
|
||||||
|
try:
|
||||||
|
from modules.datamodels.datamodelUam import Mandate
|
||||||
|
records = appDb.getRecordset(Mandate, recordFilter={"name": _MIGRATION_FLAG_KEY})
|
||||||
|
return len(records) > 0
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _setMigrationCompleted(appDb) -> None:
|
||||||
|
try:
|
||||||
|
from modules.datamodels.datamodelUam import Mandate
|
||||||
|
flag = Mandate(name=_MIGRATION_FLAG_KEY, description="RAG scope fields migration completed")
|
||||||
|
appDb.recordCreate(Mandate, flag)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Could not set migration flag: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
def runMigration(appDb=None) -> dict:
|
||||||
|
"""Backfill FileContentIndex rows from FileItem metadata.
|
||||||
|
|
||||||
|
Returns dict with counts: {total, updated, skipped, orphaned}.
|
||||||
|
"""
|
||||||
|
from modules.datamodels.datamodelKnowledge import FileContentIndex
|
||||||
|
from modules.datamodels.datamodelFiles import FileItem
|
||||||
|
from modules.interfaces.interfaceDbKnowledge import getInterface as getKnowledgeInterface
|
||||||
|
from modules.interfaces.interfaceDbManagement import ComponentObjects
|
||||||
|
|
||||||
|
if appDb is None:
|
||||||
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
|
appDb = getRootInterface().db
|
||||||
|
|
||||||
|
if _isMigrationCompleted(appDb):
|
||||||
|
logger.info("migrateRagScopeFields: already completed, skipping")
|
||||||
|
return {"total": 0, "updated": 0, "skipped": 0, "orphaned": 0}
|
||||||
|
|
||||||
|
knowDb = getKnowledgeInterface(None).db
|
||||||
|
mgmtDb = ComponentObjects().db
|
||||||
|
|
||||||
|
allIndexes = knowDb.getRecordset(FileContentIndex, recordFilter={})
|
||||||
|
total = len(allIndexes)
|
||||||
|
updated = 0
|
||||||
|
skipped = 0
|
||||||
|
orphaned = 0
|
||||||
|
|
||||||
|
logger.info("migrateRagScopeFields: processing %d FileContentIndex rows", total)
|
||||||
|
|
||||||
|
for idx in allIndexes:
|
||||||
|
idxId = idx.get("id") if isinstance(idx, dict) else getattr(idx, "id", None)
|
||||||
|
if not idxId:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
fileItem = mgmtDb._loadRecord(FileItem, str(idxId))
|
||||||
|
if not fileItem:
|
||||||
|
orphaned += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
_get = (lambda k, d="": fileItem.get(k, d)) if isinstance(fileItem, dict) else (lambda k, d="": getattr(fileItem, k, d))
|
||||||
|
|
||||||
|
fiScope = _get("scope") or "personal"
|
||||||
|
fiMandateId = str(_get("mandateId") or "")
|
||||||
|
fiFeatureInstanceId = str(_get("featureInstanceId") or "")
|
||||||
|
|
||||||
|
idxGet = (lambda k, d="": idx.get(k, d)) if isinstance(idx, dict) else (lambda k, d="": getattr(idx, k, d))
|
||||||
|
currentScope = idxGet("scope") or "personal"
|
||||||
|
currentMandateId = str(idxGet("mandateId") or "")
|
||||||
|
currentFeatureInstanceId = str(idxGet("featureInstanceId") or "")
|
||||||
|
|
||||||
|
updates = {}
|
||||||
|
if fiScope != currentScope:
|
||||||
|
updates["scope"] = fiScope
|
||||||
|
if fiMandateId and fiMandateId != currentMandateId:
|
||||||
|
updates["mandateId"] = fiMandateId
|
||||||
|
if fiFeatureInstanceId and fiFeatureInstanceId != currentFeatureInstanceId:
|
||||||
|
updates["featureInstanceId"] = fiFeatureInstanceId
|
||||||
|
|
||||||
|
if updates:
|
||||||
|
try:
|
||||||
|
knowDb.recordModify(FileContentIndex, str(idxId), updates)
|
||||||
|
updated += 1
|
||||||
|
logger.debug("migrateRagScopeFields: updated %s -> %s", idxId, updates)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("migrateRagScopeFields: failed to update %s: %s", idxId, e)
|
||||||
|
skipped += 1
|
||||||
|
else:
|
||||||
|
skipped += 1
|
||||||
|
|
||||||
|
_setMigrationCompleted(appDb)
|
||||||
|
logger.info(
|
||||||
|
"migrateRagScopeFields complete: total=%d, updated=%d, skipped=%d, orphaned=%d",
|
||||||
|
total, updated, skipped, orphaned,
|
||||||
|
)
|
||||||
|
return {"total": total, "updated": updated, "skipped": skipped, "orphaned": orphaned}
|
||||||
329
modules/migration/migrateRootUsers.py
Normal file
329
modules/migration/migrateRootUsers.py
Normal file
|
|
@ -0,0 +1,329 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""
|
||||||
|
Migration: Root-Mandant bereinigen.
|
||||||
|
Moves all end-user data from Root mandate shared instances to own mandates.
|
||||||
|
Called once from bootstrap, sets a DB flag to prevent re-execution.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_MIGRATION_FLAG_KEY = "migration_root_users_completed"
|
||||||
|
|
||||||
|
_DATA_TABLES = [
|
||||||
|
"ChatWorkflow",
|
||||||
|
"FileItem",
|
||||||
|
"DataSource",
|
||||||
|
"DataNeutralizerAttributes",
|
||||||
|
"FileContentIndex",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _isMigrationCompleted(db) -> bool:
|
||||||
|
"""Check if migration has already been executed."""
|
||||||
|
try:
|
||||||
|
from modules.datamodels.datamodelUam import Mandate
|
||||||
|
records = db.getRecordset(Mandate, recordFilter={"name": _MIGRATION_FLAG_KEY})
|
||||||
|
return len(records) > 0
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _setMigrationCompleted(db) -> None:
|
||||||
|
"""Set flag that migration is completed (uses a settings-like record)."""
|
||||||
|
if _isMigrationCompleted(db):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from modules.datamodels.datamodelUam import Mandate
|
||||||
|
flag = Mandate(name=_MIGRATION_FLAG_KEY, label="Migration completed", enabled=False, isSystem=True)
|
||||||
|
db.recordCreate(Mandate, flag)
|
||||||
|
logger.info("Migration flag set: root user migration completed")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to set migration flag: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _findOrCreateTargetInstance(db, featureInterface, featureCode: str, targetMandateId: str, rootInstance: dict) -> dict:
|
||||||
|
"""Find existing or create new FeatureInstance in target mandate. Idempotent."""
|
||||||
|
from modules.datamodels.datamodelFeatures import FeatureInstance
|
||||||
|
|
||||||
|
existing = db.getRecordset(FeatureInstance, recordFilter={
|
||||||
|
"featureCode": featureCode,
|
||||||
|
"mandateId": targetMandateId,
|
||||||
|
})
|
||||||
|
if existing:
|
||||||
|
logger.debug(f"Target instance already exists for {featureCode} in mandate {targetMandateId}")
|
||||||
|
return existing[0]
|
||||||
|
|
||||||
|
label = rootInstance.get("label") or featureCode
|
||||||
|
instance = featureInterface.createFeatureInstance(
|
||||||
|
featureCode=featureCode,
|
||||||
|
mandateId=targetMandateId,
|
||||||
|
label=label,
|
||||||
|
enabled=True,
|
||||||
|
copyTemplateRoles=True,
|
||||||
|
)
|
||||||
|
if isinstance(instance, dict):
|
||||||
|
return instance
|
||||||
|
return instance.model_dump() if hasattr(instance, "model_dump") else {"id": instance.id}
|
||||||
|
|
||||||
|
|
||||||
|
def _migrateDataRecords(db, oldInstanceId: str, newInstanceId: str, userId: str) -> int:
|
||||||
|
"""Bulk-update featureInstanceId on all data tables for records owned by userId."""
|
||||||
|
totalMigrated = 0
|
||||||
|
db._ensure_connection()
|
||||||
|
for tableName in _DATA_TABLES:
|
||||||
|
try:
|
||||||
|
with db.connection.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
f'UPDATE "{tableName}" '
|
||||||
|
f'SET "featureInstanceId" = %s '
|
||||||
|
f'WHERE "featureInstanceId" = %s AND "sysCreatedBy" = %s',
|
||||||
|
(newInstanceId, oldInstanceId, userId),
|
||||||
|
)
|
||||||
|
count = cursor.rowcount
|
||||||
|
db.connection.commit()
|
||||||
|
if count > 0:
|
||||||
|
logger.info(f" Migrated {count} rows in {tableName}: {oldInstanceId} -> {newInstanceId}")
|
||||||
|
totalMigrated += count
|
||||||
|
except Exception as e:
|
||||||
|
try:
|
||||||
|
db.connection.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
logger.debug(f" Table {tableName} skipped (may not exist or no matching column): {e}")
|
||||||
|
return totalMigrated
|
||||||
|
|
||||||
|
|
||||||
|
def _grantFeatureAccess(db, userId: str, featureInstanceId: str) -> dict:
|
||||||
|
"""Create FeatureAccess + admin role on a feature instance. Idempotent."""
|
||||||
|
from modules.datamodels.datamodelMembership import FeatureAccess, FeatureAccessRole
|
||||||
|
from modules.datamodels.datamodelRbac import Role
|
||||||
|
|
||||||
|
existing = db.getRecordset(FeatureAccess, recordFilter={
|
||||||
|
"userId": userId,
|
||||||
|
"featureInstanceId": featureInstanceId,
|
||||||
|
})
|
||||||
|
if existing:
|
||||||
|
logger.debug(f"FeatureAccess already exists for user {userId} on instance {featureInstanceId}")
|
||||||
|
return existing[0]
|
||||||
|
|
||||||
|
fa = FeatureAccess(userId=userId, featureInstanceId=featureInstanceId, enabled=True)
|
||||||
|
createdFa = db.recordCreate(FeatureAccess, fa.model_dump())
|
||||||
|
if not createdFa:
|
||||||
|
logger.warning(f"Failed to create FeatureAccess for user {userId} on instance {featureInstanceId}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
instanceRoles = db.getRecordset(Role, recordFilter={"featureInstanceId": featureInstanceId})
|
||||||
|
adminRoleId = None
|
||||||
|
for r in instanceRoles:
|
||||||
|
roleLabel = (r.get("roleLabel") or "").lower()
|
||||||
|
if roleLabel.endswith("-admin"):
|
||||||
|
adminRoleId = r.get("id")
|
||||||
|
break
|
||||||
|
if not adminRoleId:
|
||||||
|
raise ValueError(
|
||||||
|
f"No feature-specific admin role for instance {featureInstanceId}. "
|
||||||
|
f"Cannot create FeatureAccess without role — even in migration context."
|
||||||
|
)
|
||||||
|
far = FeatureAccessRole(featureAccessId=createdFa["id"], roleId=adminRoleId)
|
||||||
|
db.recordCreate(FeatureAccessRole, far.model_dump())
|
||||||
|
|
||||||
|
return createdFa
|
||||||
|
|
||||||
|
|
||||||
|
def migrateRootUsers(db, dryRun: bool = False) -> dict:
|
||||||
|
"""
|
||||||
|
Migrate all end-user feature data from Root mandate to personal mandates.
|
||||||
|
|
||||||
|
Algorithm:
|
||||||
|
STEP 1: For each user with FeatureAccess on Root instances:
|
||||||
|
- If user has own mandate: target = existing mandate
|
||||||
|
- If not: create personal mandate via _provisionMandateForUser
|
||||||
|
- For each FeatureAccess: create new instance in target, migrate data, transfer access
|
||||||
|
|
||||||
|
STEP 2: Clean up Root:
|
||||||
|
- Delete all FeatureInstances in Root
|
||||||
|
- Remove UserMandate for non-sysadmin users
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database connector
|
||||||
|
dryRun: If True, log actions without making changes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Summary dict with migration statistics
|
||||||
|
"""
|
||||||
|
if _isMigrationCompleted(db):
|
||||||
|
logger.info("Root user migration already completed, skipping")
|
||||||
|
return {"status": "already_completed"}
|
||||||
|
|
||||||
|
from modules.datamodels.datamodelUam import Mandate, User, UserInDB
|
||||||
|
from modules.datamodels.datamodelMembership import (
|
||||||
|
UserMandate, UserMandateRole, FeatureAccess, FeatureAccessRole,
|
||||||
|
)
|
||||||
|
from modules.datamodels.datamodelFeatures import FeatureInstance
|
||||||
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
|
from modules.interfaces.interfaceFeatures import getFeatureInterface
|
||||||
|
|
||||||
|
rootInterface = getRootInterface()
|
||||||
|
featureInterface = getFeatureInterface(db)
|
||||||
|
stats = {
|
||||||
|
"usersProcessed": 0,
|
||||||
|
"mandatesCreated": 0,
|
||||||
|
"instancesMigrated": 0,
|
||||||
|
"dataRowsMigrated": 0,
|
||||||
|
"rootInstancesDeleted": 0,
|
||||||
|
"rootMembershipsRemoved": 0,
|
||||||
|
"dryRun": dryRun,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find root mandate
|
||||||
|
rootMandates = db.getRecordset(Mandate, recordFilter={"name": "root", "isSystem": True})
|
||||||
|
if not rootMandates:
|
||||||
|
logger.warning("No root mandate found, nothing to migrate")
|
||||||
|
return {"status": "no_root_mandate"}
|
||||||
|
rootMandateId = rootMandates[0].get("id")
|
||||||
|
|
||||||
|
# Get all feature instances in root
|
||||||
|
rootInstances = db.getRecordset(FeatureInstance, recordFilter={"mandateId": rootMandateId})
|
||||||
|
if not rootInstances:
|
||||||
|
logger.info("No feature instances in root mandate, nothing to migrate")
|
||||||
|
if not dryRun:
|
||||||
|
_setMigrationCompleted(db)
|
||||||
|
return {"status": "no_instances", **stats}
|
||||||
|
|
||||||
|
# Get all FeatureAccess on root instances
|
||||||
|
rootInstanceIds = {inst.get("id") for inst in rootInstances}
|
||||||
|
|
||||||
|
# Collect unique users with access on root instances
|
||||||
|
usersToMigrate = {}
|
||||||
|
for instanceId in rootInstanceIds:
|
||||||
|
accesses = db.getRecordset(FeatureAccess, recordFilter={"featureInstanceId": instanceId})
|
||||||
|
for access in accesses:
|
||||||
|
userId = access.get("userId")
|
||||||
|
if userId not in usersToMigrate:
|
||||||
|
usersToMigrate[userId] = []
|
||||||
|
usersToMigrate[userId].append({
|
||||||
|
"featureAccessId": access.get("id"),
|
||||||
|
"featureInstanceId": instanceId,
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(f"Migration: {len(usersToMigrate)} users with {sum(len(v) for v in usersToMigrate.values())} accesses on {len(rootInstances)} root instances")
|
||||||
|
|
||||||
|
# STEP 1: Migrate users
|
||||||
|
for userId, accessList in usersToMigrate.items():
|
||||||
|
try:
|
||||||
|
# Find user
|
||||||
|
users = db.getRecordset(UserInDB, recordFilter={"id": userId})
|
||||||
|
if not users:
|
||||||
|
logger.warning(f"User {userId} not found, skipping")
|
||||||
|
continue
|
||||||
|
user = users[0]
|
||||||
|
username = user.get("username", "unknown")
|
||||||
|
|
||||||
|
# Check if user has own non-root mandate
|
||||||
|
userMandates = db.getRecordset(UserMandate, recordFilter={"userId": userId, "enabled": True})
|
||||||
|
targetMandateId = None
|
||||||
|
for um in userMandates:
|
||||||
|
mid = um.get("mandateId")
|
||||||
|
if mid != rootMandateId:
|
||||||
|
targetMandateId = mid
|
||||||
|
break
|
||||||
|
|
||||||
|
if not targetMandateId:
|
||||||
|
# Create personal mandate
|
||||||
|
if dryRun:
|
||||||
|
logger.info(f"[DRY RUN] Would create personal mandate for user {username}")
|
||||||
|
stats["mandatesCreated"] += 1
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
result = rootInterface._provisionMandateForUser(
|
||||||
|
userId=userId,
|
||||||
|
mandateName=f"Home {username}",
|
||||||
|
planKey="TRIAL_7D",
|
||||||
|
)
|
||||||
|
targetMandateId = result["mandateId"]
|
||||||
|
stats["mandatesCreated"] += 1
|
||||||
|
logger.info(f"Created personal mandate {targetMandateId} for user {username}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create mandate for user {username}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Migrate each FeatureAccess
|
||||||
|
for accessInfo in accessList:
|
||||||
|
oldInstanceId = accessInfo["featureInstanceId"]
|
||||||
|
oldAccessId = accessInfo["featureAccessId"]
|
||||||
|
|
||||||
|
# Find the root instance details
|
||||||
|
instRecords = db.getRecordset(FeatureInstance, recordFilter={"id": oldInstanceId})
|
||||||
|
if not instRecords:
|
||||||
|
continue
|
||||||
|
featureCode = instRecords[0].get("featureCode")
|
||||||
|
|
||||||
|
if dryRun:
|
||||||
|
logger.info(f"[DRY RUN] Would migrate {featureCode} for {username} to mandate {targetMandateId}")
|
||||||
|
stats["instancesMigrated"] += 1
|
||||||
|
else:
|
||||||
|
targetInstance = _findOrCreateTargetInstance(
|
||||||
|
db, featureInterface, featureCode, targetMandateId, instRecords[0],
|
||||||
|
)
|
||||||
|
newInstanceId = targetInstance.get("id")
|
||||||
|
if not newInstanceId:
|
||||||
|
logger.error(f"Failed to obtain target instance for {featureCode} in mandate {targetMandateId}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
migratedCount = _migrateDataRecords(db, oldInstanceId, newInstanceId, userId)
|
||||||
|
|
||||||
|
_grantFeatureAccess(db, userId, newInstanceId)
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.recordDelete(FeatureAccess, oldAccessId)
|
||||||
|
except Exception as delErr:
|
||||||
|
logger.warning(f"Could not remove old FeatureAccess {oldAccessId}: {delErr}")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Migrated {featureCode} for {username}: "
|
||||||
|
f"instance {oldInstanceId} -> {newInstanceId}, {migratedCount} data rows moved"
|
||||||
|
)
|
||||||
|
stats["instancesMigrated"] += 1
|
||||||
|
stats["dataRowsMigrated"] += migratedCount
|
||||||
|
|
||||||
|
stats["usersProcessed"] += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error migrating user {userId}: {e}")
|
||||||
|
|
||||||
|
# STEP 2: Clean up root
|
||||||
|
if not dryRun:
|
||||||
|
# Delete all feature instances in root
|
||||||
|
for inst in rootInstances:
|
||||||
|
instId = inst.get("id")
|
||||||
|
try:
|
||||||
|
# First delete all FeatureAccess on this instance
|
||||||
|
accesses = db.getRecordset(FeatureAccess, recordFilter={"featureInstanceId": instId})
|
||||||
|
for access in accesses:
|
||||||
|
db.recordDelete(FeatureAccess, access.get("id"))
|
||||||
|
db.recordDelete(FeatureInstance, instId)
|
||||||
|
stats["rootInstancesDeleted"] += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting root instance {instId}: {e}")
|
||||||
|
|
||||||
|
# Remove non-sysadmin users from root mandate
|
||||||
|
rootMembers = db.getRecordset(UserMandate, recordFilter={"mandateId": rootMandateId})
|
||||||
|
for membership in rootMembers:
|
||||||
|
membUserId = membership.get("userId")
|
||||||
|
userRecords = db.getRecordset(UserInDB, recordFilter={"id": membUserId})
|
||||||
|
if userRecords and userRecords[0].get("isSysAdmin"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
db.recordDelete(UserMandate, membership.get("id"))
|
||||||
|
stats["rootMembershipsRemoved"] += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error removing root membership for {membUserId}: {e}")
|
||||||
|
|
||||||
|
_setMigrationCompleted(db)
|
||||||
|
|
||||||
|
logger.info(f"Migration completed: {stats}")
|
||||||
|
return {"status": "completed", **stats}
|
||||||
316
modules/migration/migrateVoiceAndDocuments.py
Normal file
316
modules/migration/migrateVoiceAndDocuments.py
Normal file
|
|
@ -0,0 +1,316 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""
|
||||||
|
Migration: Voice settings consolidation and CoachingDocument scope-tagging.
|
||||||
|
Moves VoiceSettings (workspace DB) and CoachingUserProfile voice fields (commcoach DB)
|
||||||
|
into the unified UserVoicePreferences model, and tags CoachingDocument files with
|
||||||
|
featureInstance scope before deleting the legacy records.
|
||||||
|
Called once from bootstrap, sets a DB flag to prevent re-execution.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||||
|
from modules.shared.configuration import APP_CONFIG
|
||||||
|
from modules.datamodels.datamodelUam import UserVoicePreferences
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_MIGRATION_FLAG_KEY = "migration_voice_documents_completed"
|
||||||
|
|
||||||
|
|
||||||
|
def _isMigrationCompleted(db) -> bool:
|
||||||
|
"""Check if migration has already been executed."""
|
||||||
|
try:
|
||||||
|
from modules.datamodels.datamodelUam import Mandate
|
||||||
|
records = db.getRecordset(Mandate, recordFilter={"name": _MIGRATION_FLAG_KEY})
|
||||||
|
return len(records) > 0
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _setMigrationCompleted(db) -> None:
|
||||||
|
"""Set flag that migration is completed (uses a settings-like record)."""
|
||||||
|
if _isMigrationCompleted(db):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from modules.datamodels.datamodelUam import Mandate
|
||||||
|
flag = Mandate(name=_MIGRATION_FLAG_KEY, label="Migration completed", enabled=False, isSystem=True)
|
||||||
|
db.recordCreate(Mandate, flag)
|
||||||
|
logger.info("Migration flag set: voice & documents migration completed")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to set migration flag: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _getRawRows(connector: DatabaseConnector, tableName: str, columns: List[str]) -> List[Dict]:
|
||||||
|
"""Read all rows from a table via raw SQL. Returns empty list if table doesn't exist."""
|
||||||
|
try:
|
||||||
|
connector._ensure_connection()
|
||||||
|
colList = ", ".join(f'"{c}"' for c in columns)
|
||||||
|
with connector.connection.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.tables "
|
||||||
|
"WHERE LOWER(table_name) = LOWER(%s) AND table_schema = 'public'",
|
||||||
|
(tableName,),
|
||||||
|
)
|
||||||
|
if cur.fetchone()["count"] == 0:
|
||||||
|
logger.info(f"Table '{tableName}' does not exist, skipping")
|
||||||
|
return []
|
||||||
|
cur.execute(f'SELECT {colList} FROM "{tableName}"')
|
||||||
|
return [dict(row) for row in cur.fetchall()]
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Raw query on '{tableName}' failed: {e}")
|
||||||
|
try:
|
||||||
|
connector.connection.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _deleteRawRow(connector: DatabaseConnector, tableName: str, rowId: str) -> bool:
|
||||||
|
"""Delete a single row by id via raw SQL."""
|
||||||
|
try:
|
||||||
|
connector._ensure_connection()
|
||||||
|
with connector.connection.cursor() as cur:
|
||||||
|
cur.execute(f'DELETE FROM "{tableName}" WHERE "id" = %s', (rowId,))
|
||||||
|
connector.connection.commit()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to delete row {rowId} from '{tableName}': {e}")
|
||||||
|
try:
|
||||||
|
connector.connection.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _createDbConnector(dbName: str) -> Optional[DatabaseConnector]:
|
||||||
|
"""Create a DatabaseConnector for a named database, returns None on failure."""
|
||||||
|
try:
|
||||||
|
dbHost = APP_CONFIG.get("DB_HOST")
|
||||||
|
dbUser = APP_CONFIG.get("DB_USER")
|
||||||
|
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
||||||
|
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
||||||
|
return DatabaseConnector(
|
||||||
|
dbHost=dbHost,
|
||||||
|
dbDatabase=dbName,
|
||||||
|
dbUser=dbUser,
|
||||||
|
dbPassword=dbPassword,
|
||||||
|
dbPort=dbPort,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not connect to database '{dbName}': {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Part A ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _migrateVoiceSettings(db, wsDb: DatabaseConnector, dryRun: bool, stats: Dict) -> None:
|
||||||
|
"""Migrate VoiceSettings records from poweron_workspace into UserVoicePreferences."""
|
||||||
|
rows = _getRawRows(wsDb, "VoiceSettings", [
|
||||||
|
"id", "userId", "mandateId", "ttsVoiceMap", "sttLanguage", "ttsLanguage", "ttsVoice",
|
||||||
|
])
|
||||||
|
if not rows:
|
||||||
|
logger.info("Part A: No VoiceSettings records found, skipping")
|
||||||
|
return
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
userId = row.get("userId")
|
||||||
|
if not userId:
|
||||||
|
continue
|
||||||
|
|
||||||
|
existing = db.getRecordset(UserVoicePreferences, recordFilter={"userId": userId})
|
||||||
|
if existing:
|
||||||
|
stats["voiceSettingsSkipped"] += 1
|
||||||
|
if not dryRun:
|
||||||
|
_deleteRawRow(wsDb, "VoiceSettings", row["id"])
|
||||||
|
continue
|
||||||
|
|
||||||
|
if dryRun:
|
||||||
|
logger.info(f"[DRY RUN] Would create UserVoicePreferences for user {userId} from VoiceSettings")
|
||||||
|
stats["voiceSettingsCreated"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
ttsVoiceMap = row.get("ttsVoiceMap")
|
||||||
|
if isinstance(ttsVoiceMap, str):
|
||||||
|
try:
|
||||||
|
ttsVoiceMap = json.loads(ttsVoiceMap)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
ttsVoiceMap = None
|
||||||
|
|
||||||
|
prefs = UserVoicePreferences(
|
||||||
|
userId=userId,
|
||||||
|
mandateId=row.get("mandateId"),
|
||||||
|
ttsVoiceMap=ttsVoiceMap,
|
||||||
|
sttLanguage=row.get("sttLanguage", "de-DE"),
|
||||||
|
ttsLanguage=row.get("ttsLanguage", "de-DE"),
|
||||||
|
ttsVoice=row.get("ttsVoice"),
|
||||||
|
)
|
||||||
|
db.recordCreate(UserVoicePreferences, prefs)
|
||||||
|
stats["voiceSettingsCreated"] += 1
|
||||||
|
_deleteRawRow(wsDb, "VoiceSettings", row["id"])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Part A: Failed to migrate VoiceSettings {row['id']}: {e}")
|
||||||
|
stats["errors"] += 1
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Part B ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _migrateCoachingProfileVoice(db, ccDb: DatabaseConnector, dryRun: bool, stats: Dict) -> None:
|
||||||
|
"""Migrate preferredLanguage/preferredVoice from CoachingUserProfile into UserVoicePreferences."""
|
||||||
|
rows = _getRawRows(ccDb, "CoachingUserProfile", [
|
||||||
|
"id", "userId", "mandateId", "preferredLanguage", "preferredVoice",
|
||||||
|
])
|
||||||
|
if not rows:
|
||||||
|
logger.info("Part B: No CoachingUserProfile records with voice data found, skipping")
|
||||||
|
return
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
userId = row.get("userId")
|
||||||
|
prefLang = row.get("preferredLanguage")
|
||||||
|
prefVoice = row.get("preferredVoice")
|
||||||
|
if not userId or (not prefLang and not prefVoice):
|
||||||
|
continue
|
||||||
|
|
||||||
|
existing = db.getRecordset(UserVoicePreferences, recordFilter={"userId": userId})
|
||||||
|
if existing:
|
||||||
|
stats["coachingProfileSkipped"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if dryRun:
|
||||||
|
logger.info(f"[DRY RUN] Would create UserVoicePreferences for user {userId} from CoachingUserProfile")
|
||||||
|
stats["coachingProfileCreated"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
prefs = UserVoicePreferences(
|
||||||
|
userId=userId,
|
||||||
|
mandateId=row.get("mandateId"),
|
||||||
|
sttLanguage=prefLang or "de-DE",
|
||||||
|
ttsLanguage=prefLang or "de-DE",
|
||||||
|
ttsVoice=prefVoice,
|
||||||
|
)
|
||||||
|
db.recordCreate(UserVoicePreferences, prefs)
|
||||||
|
stats["coachingProfileCreated"] += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Part B: Failed to migrate CoachingUserProfile {row['id']}: {e}")
|
||||||
|
stats["errors"] += 1
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Part C ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _migrateCoachingDocuments(ccDb: DatabaseConnector, dryRun: bool, stats: Dict) -> None:
|
||||||
|
"""Tag FileItem/FileContentIndex with featureInstance scope for each CoachingDocument."""
|
||||||
|
from modules.datamodels.datamodelFiles import FileItem
|
||||||
|
from modules.datamodels.datamodelKnowledge import FileContentIndex
|
||||||
|
|
||||||
|
rows = _getRawRows(ccDb, "CoachingDocument", [
|
||||||
|
"id", "fileRef", "instanceId",
|
||||||
|
])
|
||||||
|
if not rows:
|
||||||
|
logger.info("Part C: No CoachingDocument records found, skipping")
|
||||||
|
return
|
||||||
|
|
||||||
|
mgmtDb = _createDbConnector("poweron_management")
|
||||||
|
knowledgeDb = _createDbConnector("poweron_knowledge")
|
||||||
|
if not mgmtDb:
|
||||||
|
logger.error("Part C: Cannot connect to poweron_management, aborting document migration")
|
||||||
|
return
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
fileRef = row.get("fileRef")
|
||||||
|
instanceId = row.get("instanceId")
|
||||||
|
docId = row.get("id")
|
||||||
|
if not fileRef:
|
||||||
|
if not dryRun:
|
||||||
|
_deleteRawRow(ccDb, "CoachingDocument", docId)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if dryRun:
|
||||||
|
logger.info(f"[DRY RUN] Would tag FileItem {fileRef} with featureInstanceId={instanceId}")
|
||||||
|
stats["documentsTagged"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
fileRecords = mgmtDb.getRecordset(FileItem, recordFilter={"id": fileRef})
|
||||||
|
if fileRecords:
|
||||||
|
updateData = {"scope": "featureInstance"}
|
||||||
|
if instanceId:
|
||||||
|
updateData["featureInstanceId"] = instanceId
|
||||||
|
mgmtDb.recordModify(FileItem, fileRef, updateData)
|
||||||
|
stats["documentsTagged"] += 1
|
||||||
|
else:
|
||||||
|
logger.warning(f"Part C: FileItem {fileRef} not found in management DB")
|
||||||
|
|
||||||
|
if knowledgeDb:
|
||||||
|
fciRecords = knowledgeDb.getRecordset(FileContentIndex, recordFilter={"id": fileRef})
|
||||||
|
if fciRecords:
|
||||||
|
fciUpdate = {"scope": "featureInstance"}
|
||||||
|
if instanceId:
|
||||||
|
fciUpdate["featureInstanceId"] = instanceId
|
||||||
|
knowledgeDb.recordModify(FileContentIndex, fileRef, fciUpdate)
|
||||||
|
|
||||||
|
_deleteRawRow(ccDb, "CoachingDocument", docId)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Part C: Failed to migrate CoachingDocument {docId}: {e}")
|
||||||
|
stats["errors"] += 1
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Main entry ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def migrateVoiceAndDocuments(db, dryRun: bool = False) -> dict:
|
||||||
|
"""
|
||||||
|
Migrate VoiceSettings + CoachingUserProfile voice fields into UserVoicePreferences,
|
||||||
|
and tag CoachingDocument files with featureInstance scope.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Root database connector (poweron_app)
|
||||||
|
dryRun: If True, log actions without making changes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Summary dict with migration statistics
|
||||||
|
"""
|
||||||
|
if _isMigrationCompleted(db):
|
||||||
|
logger.info("Voice & documents migration already completed, skipping")
|
||||||
|
return {"status": "already_completed"}
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
"voiceSettingsCreated": 0,
|
||||||
|
"voiceSettingsSkipped": 0,
|
||||||
|
"coachingProfileCreated": 0,
|
||||||
|
"coachingProfileSkipped": 0,
|
||||||
|
"documentsTagged": 0,
|
||||||
|
"errors": 0,
|
||||||
|
"dryRun": dryRun,
|
||||||
|
}
|
||||||
|
|
||||||
|
wsDb = _createDbConnector("poweron_workspace")
|
||||||
|
ccDb = _createDbConnector("poweron_commcoach")
|
||||||
|
|
||||||
|
# Part A
|
||||||
|
if wsDb:
|
||||||
|
_migrateVoiceSettings(db, wsDb, dryRun, stats)
|
||||||
|
else:
|
||||||
|
logger.warning("Skipping Part A: poweron_workspace DB unavailable")
|
||||||
|
|
||||||
|
# Part B
|
||||||
|
if ccDb:
|
||||||
|
_migrateCoachingProfileVoice(db, ccDb, dryRun, stats)
|
||||||
|
else:
|
||||||
|
logger.warning("Skipping Part B: poweron_commcoach DB unavailable")
|
||||||
|
|
||||||
|
# Part C
|
||||||
|
if ccDb:
|
||||||
|
_migrateCoachingDocuments(ccDb, dryRun, stats)
|
||||||
|
else:
|
||||||
|
logger.warning("Skipping Part C: poweron_commcoach DB unavailable")
|
||||||
|
|
||||||
|
if not dryRun:
|
||||||
|
_setMigrationCompleted(db)
|
||||||
|
|
||||||
|
logger.info(f"Voice & documents migration completed: {stats}")
|
||||||
|
return {"status": "completed", **stats}
|
||||||
|
|
@ -112,12 +112,12 @@ def _buildEnrichedAutomationEvents(currentUser: User) -> List[Dict[str, Any]]:
|
||||||
if automation:
|
if automation:
|
||||||
if isinstance(automation, dict):
|
if isinstance(automation, dict):
|
||||||
job["name"] = automation.get("label", "")
|
job["name"] = automation.get("label", "")
|
||||||
job["createdBy"] = _resolveUsername(automation.get("_createdBy", ""))
|
job["createdBy"] = _resolveUsername(automation.get("sysCreatedBy", ""))
|
||||||
job["mandate"] = _resolveMandateLabel(automation.get("mandateId", ""))
|
job["mandate"] = _resolveMandateLabel(automation.get("mandateId", ""))
|
||||||
job["featureInstance"] = _resolveFeatureLabel(automation.get("featureInstanceId", ""))
|
job["featureInstance"] = _resolveFeatureLabel(automation.get("featureInstanceId", ""))
|
||||||
else:
|
else:
|
||||||
job["name"] = getattr(automation, "label", "")
|
job["name"] = getattr(automation, "label", "")
|
||||||
job["createdBy"] = _resolveUsername(getattr(automation, "_createdBy", ""))
|
job["createdBy"] = _resolveUsername(getattr(automation, "sysCreatedBy", ""))
|
||||||
job["mandate"] = _resolveMandateLabel(getattr(automation, "mandateId", ""))
|
job["mandate"] = _resolveMandateLabel(getattr(automation, "mandateId", ""))
|
||||||
job["featureInstance"] = _resolveFeatureLabel(getattr(automation, "featureInstanceId", ""))
|
job["featureInstance"] = _resolveFeatureLabel(getattr(automation, "featureInstanceId", ""))
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -91,14 +91,14 @@ def _buildFlattenedExecutionLogs(currentUser: User) -> List[Dict[str, Any]]:
|
||||||
automationLabel = automation.get("label", "")
|
automationLabel = automation.get("label", "")
|
||||||
mandateId = automation.get("mandateId", "")
|
mandateId = automation.get("mandateId", "")
|
||||||
featureInstanceId = automation.get("featureInstanceId", "")
|
featureInstanceId = automation.get("featureInstanceId", "")
|
||||||
createdBy = automation.get("_createdBy", "")
|
createdBy = automation.get("sysCreatedBy", "")
|
||||||
logs = automation.get("executionLogs") or []
|
logs = automation.get("executionLogs") or []
|
||||||
else:
|
else:
|
||||||
automationId = getattr(automation, "id", "")
|
automationId = getattr(automation, "id", "")
|
||||||
automationLabel = getattr(automation, "label", "")
|
automationLabel = getattr(automation, "label", "")
|
||||||
mandateId = getattr(automation, "mandateId", "")
|
mandateId = getattr(automation, "mandateId", "")
|
||||||
featureInstanceId = getattr(automation, "featureInstanceId", "")
|
featureInstanceId = getattr(automation, "featureInstanceId", "")
|
||||||
createdBy = getattr(automation, "_createdBy", "")
|
createdBy = getattr(automation, "sysCreatedBy", "")
|
||||||
logs = getattr(automation, "executionLogs", None) or []
|
logs = getattr(automation, "executionLogs", None) or []
|
||||||
|
|
||||||
mandateName = _resolveMandateLabel(mandateId)
|
mandateName = _resolveMandateLabel(mandateId)
|
||||||
|
|
|
||||||
|
|
@ -576,14 +576,15 @@ def create_feature_instance(
|
||||||
config=data.config
|
config=data.config
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sync Stripe quantity after successful creation
|
|
||||||
try:
|
try:
|
||||||
from modules.interfaces.interfaceDbSubscription import getInterface as _getSubIf2
|
from modules.interfaces.interfaceDbSubscription import getInterface as _getSubIf2
|
||||||
from modules.security.rootAccess import getRootUser as _getRU
|
from modules.security.rootAccess import getRootUser as _getRU
|
||||||
_subIf2 = _getSubIf2(_getRU(), mandateIdStr)
|
_subIf2 = _getSubIf2(_getRU(), mandateIdStr)
|
||||||
_subIf2.syncQuantityToStripe(mandateIdStr)
|
_operative = _subIf2.getOperativeForMandate(mandateIdStr)
|
||||||
except Exception:
|
if _operative:
|
||||||
pass
|
_subIf2.syncQuantityToStripe(_operative["id"], raiseOnError=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Stripe quantity sync failed for admin feature creation in mandate %s: %s", mandateIdStr, e)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"User {context.user.id} created feature instance '{data.label}' "
|
f"User {context.user.id} created feature instance '{data.label}' "
|
||||||
|
|
@ -1172,31 +1173,29 @@ def add_user_to_feature_instance(
|
||||||
detail=f"User '{data.userId}' not found"
|
detail=f"User '{data.userId}' not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if user already has access
|
if not data.roleIds:
|
||||||
from modules.datamodels.datamodelMembership import FeatureAccess, FeatureAccessRole
|
|
||||||
existingAccess = rootInterface.getFeatureAccess(data.userId, instanceId)
|
|
||||||
if existingAccess:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="User already has access to this feature instance"
|
detail="At least one role is required to grant feature access"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create FeatureAccess record
|
from modules.datamodels.datamodelRbac import Role
|
||||||
featureAccess = FeatureAccess(
|
instanceRoles = rootInterface.db.getRecordset(Role, recordFilter={"featureInstanceId": instanceId})
|
||||||
|
validRoleIds = {r.get("id") for r in instanceRoles}
|
||||||
|
invalidRoles = [rid for rid in data.roleIds if rid not in validRoleIds]
|
||||||
|
if invalidRoles:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Role IDs {invalidRoles} do not belong to feature instance {instanceId}. "
|
||||||
|
f"Only instance-scoped roles are allowed, never mandate roles."
|
||||||
|
)
|
||||||
|
|
||||||
|
featureAccess = rootInterface.createFeatureAccess(
|
||||||
userId=data.userId,
|
userId=data.userId,
|
||||||
featureInstanceId=instanceId,
|
featureInstanceId=instanceId,
|
||||||
enabled=True
|
roleIds=data.roleIds
|
||||||
)
|
)
|
||||||
createdAccess = rootInterface.db.recordCreate(FeatureAccess, featureAccess.model_dump())
|
featureAccessId = str(featureAccess.id)
|
||||||
featureAccessId = createdAccess.get("id")
|
|
||||||
|
|
||||||
# Create FeatureAccessRole records for each role
|
|
||||||
for roleId in data.roleIds:
|
|
||||||
featureAccessRole = FeatureAccessRole(
|
|
||||||
featureAccessId=featureAccessId,
|
|
||||||
roleId=roleId
|
|
||||||
)
|
|
||||||
rootInterface.db.recordCreate(FeatureAccessRole, featureAccessRole.model_dump())
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"User {context.user.id} added user {data.userId} to feature instance {instanceId} "
|
f"User {context.user.id} added user {data.userId} to feature instance {instanceId} "
|
||||||
|
|
@ -1379,10 +1378,19 @@ def update_feature_instance_user_roles(
|
||||||
if data.enabled is not None:
|
if data.enabled is not None:
|
||||||
rootInterface.db.recordModify(FeatureAccess, featureAccessId, {"enabled": data.enabled})
|
rootInterface.db.recordModify(FeatureAccess, featureAccessId, {"enabled": data.enabled})
|
||||||
|
|
||||||
# Delete existing FeatureAccessRole records via interface method
|
from modules.datamodels.datamodelRbac import Role
|
||||||
|
instanceRoles = rootInterface.db.getRecordset(Role, recordFilter={"featureInstanceId": instanceId})
|
||||||
|
validRoleIds = {r.get("id") for r in instanceRoles}
|
||||||
|
invalidRoles = [rid for rid in data.roleIds if rid not in validRoleIds]
|
||||||
|
if invalidRoles:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Role IDs {invalidRoles} do not belong to feature instance {instanceId}. "
|
||||||
|
f"Only instance-scoped roles are allowed, never mandate roles."
|
||||||
|
)
|
||||||
|
|
||||||
rootInterface.deleteFeatureAccessRoles(featureAccessId)
|
rootInterface.deleteFeatureAccessRoles(featureAccessId)
|
||||||
|
|
||||||
# Create new FeatureAccessRole records
|
|
||||||
for roleId in data.roleIds:
|
for roleId in data.roleIds:
|
||||||
featureAccessRole = FeatureAccessRole(
|
featureAccessRole = FeatureAccessRole(
|
||||||
featureAccessId=featureAccessId,
|
featureAccessId=featureAccessId,
|
||||||
|
|
@ -1523,6 +1531,65 @@ def get_feature(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Instance Rename (for instance admins, used by navigation tree)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class FeatureInstanceRenameRequest(BaseModel):
|
||||||
|
"""Request model for renaming a feature instance"""
|
||||||
|
label: str = Field(..., min_length=1, max_length=200, description="New label for the instance")
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/instances/{instanceId}/rename", response_model=Dict[str, Any])
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
def _renameFeatureInstance(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str,
|
||||||
|
data: FeatureInstanceRenameRequest,
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Rename a feature instance. Requires instance admin role.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
rootInterface = getRootInterface()
|
||||||
|
featureInterface = getFeatureInterface(rootInterface.db)
|
||||||
|
|
||||||
|
instance = featureInterface.getFeatureInstance(instanceId)
|
||||||
|
if not instance:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feature instance not found")
|
||||||
|
|
||||||
|
userId = str(context.user.id)
|
||||||
|
isInstanceAdmin = False
|
||||||
|
if context.hasSysAdminRole:
|
||||||
|
isInstanceAdmin = True
|
||||||
|
else:
|
||||||
|
from modules.datamodels.datamodelMembership import FeatureAccess, FeatureAccessRole
|
||||||
|
fa = rootInterface.getFeatureAccess(userId, instanceId)
|
||||||
|
if fa:
|
||||||
|
faRoleIds = rootInterface.getRoleIdsForFeatureAccess(str(fa.id))
|
||||||
|
for rid in faRoleIds:
|
||||||
|
role = rootInterface.getRole(rid)
|
||||||
|
if role and (role.roleLabel or "").lower().endswith("-admin"):
|
||||||
|
isInstanceAdmin = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not isInstanceAdmin:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Instance admin role required to rename")
|
||||||
|
|
||||||
|
updated = featureInterface.updateFeatureInstance(instanceId, {"label": data.label.strip()})
|
||||||
|
if not updated:
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update instance")
|
||||||
|
|
||||||
|
return {"id": instanceId, "label": updated.label}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error renaming feature instance {instanceId}: {e}")
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Helper Functions
|
# Helper Functions
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -1477,7 +1477,7 @@ def cleanup_duplicate_access_rules(
|
||||||
for sig, rules in rulesBySignature.items():
|
for sig, rules in rulesBySignature.items():
|
||||||
if len(rules) > 1:
|
if len(rules) > 1:
|
||||||
# Sort by creation time (keep oldest)
|
# Sort by creation time (keep oldest)
|
||||||
rules.sort(key=lambda r: r.get("_createdAt", 0))
|
rules.sort(key=lambda r: r.get("sysCreatedAt", 0))
|
||||||
keepRule = rules[0]
|
keepRule = rules[0]
|
||||||
deleteRules = rules[1:]
|
deleteRules = rules[1:]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Resp
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
from fastapi import status
|
from fastapi import status
|
||||||
import logging
|
import logging
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime, timezone
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
# Import auth module
|
# Import auth module
|
||||||
|
|
@ -30,7 +30,6 @@ from modules.datamodels.datamodelBilling import (
|
||||||
BillingAccount,
|
BillingAccount,
|
||||||
BillingTransaction,
|
BillingTransaction,
|
||||||
BillingSettings,
|
BillingSettings,
|
||||||
BillingModelEnum,
|
|
||||||
TransactionTypeEnum,
|
TransactionTypeEnum,
|
||||||
ReferenceTypeEnum,
|
ReferenceTypeEnum,
|
||||||
PeriodTypeEnum,
|
PeriodTypeEnum,
|
||||||
|
|
@ -38,7 +37,6 @@ from modules.datamodels.datamodelBilling import (
|
||||||
BillingStatisticsResponse,
|
BillingStatisticsResponse,
|
||||||
BillingStatisticsChartData,
|
BillingStatisticsChartData,
|
||||||
BillingCheckResult,
|
BillingCheckResult,
|
||||||
parseBillingModelFromStoredValue,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Configure logger
|
# Configure logger
|
||||||
|
|
@ -229,14 +227,14 @@ def _filterTransactionsByScope(transactions: list, scope: BillingDataScope) -> l
|
||||||
|
|
||||||
class CreditAddRequest(BaseModel):
|
class CreditAddRequest(BaseModel):
|
||||||
"""Request model for adding or deducting credit from an account."""
|
"""Request model for adding or deducting credit from an account."""
|
||||||
userId: Optional[str] = Field(None, description="Target user ID (for PREPAY_USER model)")
|
userId: Optional[str] = Field(None, description="Target user ID for audit trail only (optional)")
|
||||||
amount: float = Field(..., description="Amount in CHF. Positive = credit, negative = deduction. Must not be zero.")
|
amount: float = Field(..., description="Amount in CHF. Positive = credit, negative = deduction. Must not be zero.")
|
||||||
description: str = Field(default="Manual credit", description="Transaction description")
|
description: str = Field(default="Manual credit", description="Transaction description")
|
||||||
|
|
||||||
|
|
||||||
class CheckoutCreateRequest(BaseModel):
|
class CheckoutCreateRequest(BaseModel):
|
||||||
"""Request model for creating Stripe Checkout Session."""
|
"""Request model for creating Stripe Checkout Session."""
|
||||||
userId: Optional[str] = Field(None, description="Target user ID (for PREPAY_USER model)")
|
userId: Optional[str] = Field(None, description="Target user ID for audit trail only (optional)")
|
||||||
amount: float = Field(..., gt=0, description="Amount to pay in CHF (must be in allowed presets)")
|
amount: float = Field(..., gt=0, description="Amount to pay in CHF (must be in allowed presets)")
|
||||||
returnUrl: str = Field(..., min_length=1, description="Absolute frontend URL used for Stripe success/cancel redirects")
|
returnUrl: str = Field(..., min_length=1, description="Absolute frontend URL used for Stripe success/cancel redirects")
|
||||||
|
|
||||||
|
|
@ -262,11 +260,12 @@ class CheckoutConfirmResponse(BaseModel):
|
||||||
|
|
||||||
class BillingSettingsUpdate(BaseModel):
|
class BillingSettingsUpdate(BaseModel):
|
||||||
"""Request model for updating billing settings."""
|
"""Request model for updating billing settings."""
|
||||||
billingModel: Optional[BillingModelEnum] = None
|
|
||||||
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)
|
||||||
notifyOnWarning: Optional[bool] = None
|
notifyOnWarning: Optional[bool] = None
|
||||||
notifyEmails: Optional[List[str]] = None
|
notifyEmails: Optional[List[str]] = None
|
||||||
|
autoRechargeEnabled: Optional[bool] = None
|
||||||
|
rechargeAmountCHF: Optional[float] = Field(None, gt=0)
|
||||||
|
rechargeMaxPerMonth: Optional[int] = Field(None, ge=0)
|
||||||
|
|
||||||
|
|
||||||
class TransactionResponse(BaseModel):
|
class TransactionResponse(BaseModel):
|
||||||
|
|
@ -293,7 +292,6 @@ class AccountSummary(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
mandateId: str
|
mandateId: str
|
||||||
userId: Optional[str]
|
userId: Optional[str]
|
||||||
accountType: str
|
|
||||||
balance: float
|
balance: float
|
||||||
warningThreshold: float
|
warningThreshold: float
|
||||||
enabled: bool
|
enabled: bool
|
||||||
|
|
@ -317,10 +315,8 @@ class MandateBalanceResponse(BaseModel):
|
||||||
"""Mandate-level balance summary."""
|
"""Mandate-level balance summary."""
|
||||||
mandateId: str
|
mandateId: str
|
||||||
mandateName: str
|
mandateName: str
|
||||||
billingModel: str
|
|
||||||
totalBalance: float
|
totalBalance: float
|
||||||
userCount: int
|
userCount: int
|
||||||
defaultUserCredit: float
|
|
||||||
warningThresholdPercent: float
|
warningThresholdPercent: float
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -414,15 +410,7 @@ 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 = parseBillingModelFromStoredValue(settings.get("billingModel"))
|
account = billingInterface.getOrCreateMandateAccount(mandate_id, initialBalance=0.0)
|
||||||
if billing_model == BillingModelEnum.PREPAY_USER:
|
|
||||||
if not user_id:
|
|
||||||
raise HTTPException(status_code=400, detail="userId required for PREPAY_USER")
|
|
||||||
account = billingInterface.getOrCreateUserAccount(mandate_id, user_id, initialBalance=0.0)
|
|
||||||
elif billing_model == BillingModelEnum.PREPAY_MANDATE:
|
|
||||||
account = billingInterface.getOrCreateMandateAccount(mandate_id, initialBalance=0.0)
|
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Cannot add credit to {billing_model.value}")
|
|
||||||
|
|
||||||
transaction = BillingTransaction(
|
transaction = BillingTransaction(
|
||||||
accountId=account["id"],
|
accountId=account["id"],
|
||||||
|
|
@ -516,7 +504,6 @@ def getBalanceForMandate(
|
||||||
return BillingBalanceResponse(
|
return BillingBalanceResponse(
|
||||||
mandateId=targetMandateId,
|
mandateId=targetMandateId,
|
||||||
mandateName=mandateName,
|
mandateName=mandateName,
|
||||||
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,
|
||||||
|
|
@ -564,7 +551,7 @@ def getTransactions(
|
||||||
aicoreProvider=t.get("aicoreProvider"),
|
aicoreProvider=t.get("aicoreProvider"),
|
||||||
aicoreModel=t.get("aicoreModel"),
|
aicoreModel=t.get("aicoreModel"),
|
||||||
createdByUserId=t.get("createdByUserId"),
|
createdByUserId=t.get("createdByUserId"),
|
||||||
createdAt=t.get("_createdAt"),
|
createdAt=t.get("sysCreatedAt"),
|
||||||
mandateId=t.get("mandateId"),
|
mandateId=t.get("mandateId"),
|
||||||
mandateName=t.get("mandateName")
|
mandateName=t.get("mandateName")
|
||||||
))
|
))
|
||||||
|
|
@ -608,8 +595,6 @@ def getStatistics(
|
||||||
costByFeature={}
|
costByFeature={}
|
||||||
)
|
)
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
|
@ -722,11 +707,13 @@ def createOrUpdateSettings(
|
||||||
targetMandateId: str = Path(..., description="Mandate ID"),
|
targetMandateId: str = Path(..., description="Mandate ID"),
|
||||||
settingsUpdate: BillingSettingsUpdate = Body(...),
|
settingsUpdate: BillingSettingsUpdate = Body(...),
|
||||||
ctx: RequestContext = Depends(getRequestContext),
|
ctx: RequestContext = Depends(getRequestContext),
|
||||||
_admin = Depends(requireSysAdminRole)
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Create or update billing settings for a mandate (SysAdmin only).
|
Create or update billing settings for a mandate.
|
||||||
|
Access: SysAdmin (any mandate) or MandateAdmin (own mandate).
|
||||||
"""
|
"""
|
||||||
|
if not _isAdminOfMandate(ctx, targetMandateId):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required for this mandate")
|
||||||
try:
|
try:
|
||||||
billingInterface = getBillingInterface(ctx.user, targetMandateId)
|
billingInterface = getBillingInterface(ctx.user, targetMandateId)
|
||||||
existingSettings = billingInterface.getSettings(targetMandateId)
|
existingSettings = billingInterface.getSettings(targetMandateId)
|
||||||
|
|
@ -734,18 +721,6 @@ def createOrUpdateSettings(
|
||||||
if existingSettings:
|
if existingSettings:
|
||||||
updates = settingsUpdate.model_dump(exclude_none=True)
|
updates = settingsUpdate.model_dump(exclude_none=True)
|
||||||
if updates:
|
if updates:
|
||||||
# Check if billing model is changing - trigger budget migration
|
|
||||||
if "billingModel" in updates:
|
|
||||||
oldModel = parseBillingModelFromStoredValue(existingSettings.get("billingModel"))
|
|
||||||
newModel = (
|
|
||||||
BillingModelEnum(updates["billingModel"])
|
|
||||||
if isinstance(updates["billingModel"], str)
|
|
||||||
else updates["billingModel"]
|
|
||||||
)
|
|
||||||
if oldModel != newModel:
|
|
||||||
migrationResult = billingInterface.switchBillingModel(targetMandateId, oldModel, newModel)
|
|
||||||
logger.info(f"Billing model migration for {targetMandateId}: {migrationResult}")
|
|
||||||
|
|
||||||
result = billingInterface.updateSettings(existingSettings["id"], updates)
|
result = billingInterface.updateSettings(existingSettings["id"], updates)
|
||||||
return result or existingSettings
|
return result or existingSettings
|
||||||
return existingSettings
|
return existingSettings
|
||||||
|
|
@ -754,16 +729,6 @@ def createOrUpdateSettings(
|
||||||
|
|
||||||
newSettings = BillingSettings(
|
newSettings = BillingSettings(
|
||||||
mandateId=targetMandateId,
|
mandateId=targetMandateId,
|
||||||
billingModel=(
|
|
||||||
settingsUpdate.billingModel
|
|
||||||
if settingsUpdate.billingModel is not None
|
|
||||||
else BillingModelEnum.PREPAY_MANDATE
|
|
||||||
),
|
|
||||||
defaultUserCredit=(
|
|
||||||
settingsUpdate.defaultUserCredit
|
|
||||||
if settingsUpdate.defaultUserCredit is not None
|
|
||||||
else 0.0
|
|
||||||
),
|
|
||||||
warningThresholdPercent=(
|
warningThresholdPercent=(
|
||||||
settingsUpdate.warningThresholdPercent
|
settingsUpdate.warningThresholdPercent
|
||||||
if settingsUpdate.warningThresholdPercent is not None
|
if settingsUpdate.warningThresholdPercent is not None
|
||||||
|
|
@ -775,6 +740,21 @@ def createOrUpdateSettings(
|
||||||
else True
|
else True
|
||||||
),
|
),
|
||||||
notifyEmails=settingsUpdate.notifyEmails or [],
|
notifyEmails=settingsUpdate.notifyEmails or [],
|
||||||
|
autoRechargeEnabled=(
|
||||||
|
settingsUpdate.autoRechargeEnabled
|
||||||
|
if settingsUpdate.autoRechargeEnabled is not None
|
||||||
|
else False
|
||||||
|
),
|
||||||
|
rechargeAmountCHF=(
|
||||||
|
settingsUpdate.rechargeAmountCHF
|
||||||
|
if settingsUpdate.rechargeAmountCHF is not None
|
||||||
|
else 10.0
|
||||||
|
),
|
||||||
|
rechargeMaxPerMonth=(
|
||||||
|
settingsUpdate.rechargeMaxPerMonth
|
||||||
|
if settingsUpdate.rechargeMaxPerMonth is not None
|
||||||
|
else 3
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
return billingInterface.createSettings(newSettings)
|
return billingInterface.createSettings(newSettings)
|
||||||
|
|
@ -797,34 +777,15 @@ def addCredit(
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Add credit to a billing account (SysAdmin only).
|
Add credit to a billing account (SysAdmin only).
|
||||||
For PREPAY_USER model, specify userId. For PREPAY_MANDATE, leave userId empty.
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Get settings to determine billing model
|
|
||||||
billingInterface = getBillingInterface(ctx.user, targetMandateId)
|
billingInterface = getBillingInterface(ctx.user, targetMandateId)
|
||||||
settings = billingInterface.getSettings(targetMandateId)
|
settings = billingInterface.getSettings(targetMandateId)
|
||||||
|
|
||||||
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 = parseBillingModelFromStoredValue(settings.get("billingModel"))
|
account = billingInterface.getOrCreateMandateAccount(targetMandateId, initialBalance=0.0)
|
||||||
|
|
||||||
# Validate request based on billing model
|
|
||||||
if billingModel == BillingModelEnum.PREPAY_USER:
|
|
||||||
if not creditRequest.userId:
|
|
||||||
raise HTTPException(status_code=400, detail="userId is required for PREPAY_USER model")
|
|
||||||
|
|
||||||
# Create user-level account if needed and add credit
|
|
||||||
account = billingInterface.getOrCreateUserAccount(
|
|
||||||
targetMandateId,
|
|
||||||
creditRequest.userId,
|
|
||||||
initialBalance=0.0
|
|
||||||
)
|
|
||||||
elif billingModel == BillingModelEnum.PREPAY_MANDATE:
|
|
||||||
# Create mandate-level account if needed and add credit
|
|
||||||
account = billingInterface.getOrCreateMandateAccount(targetMandateId, initialBalance=0.0)
|
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Cannot add credit to {billingModel.value} billing model")
|
|
||||||
|
|
||||||
if creditRequest.amount == 0:
|
if creditRequest.amount == 0:
|
||||||
raise HTTPException(status_code=400, detail="Amount must not be zero")
|
raise HTTPException(status_code=400, detail="Amount must not be zero")
|
||||||
|
|
@ -867,8 +828,7 @@ def createCheckoutSession(
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Create Stripe Checkout Session for credit top-up. Returns redirect URL.
|
Create Stripe Checkout Session for credit top-up. Returns redirect URL.
|
||||||
RBAC: PREPAY_USER requires mandate membership (user loads own account),
|
Requires mandate admin role.
|
||||||
PREPAY_MANDATE requires mandate admin role.
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
billingInterface = getBillingInterface(ctx.user, targetMandateId)
|
billingInterface = getBillingInterface(ctx.user, targetMandateId)
|
||||||
|
|
@ -877,20 +837,8 @@ 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 = parseBillingModelFromStoredValue(settings.get("billingModel"))
|
if not _isAdminOfMandate(ctx, targetMandateId):
|
||||||
|
raise HTTPException(status_code=403, detail="Mandate admin role required to load mandate credit")
|
||||||
if billingModel == BillingModelEnum.PREPAY_USER:
|
|
||||||
if not checkoutRequest.userId:
|
|
||||||
raise HTTPException(status_code=400, detail="userId is required for PREPAY_USER model")
|
|
||||||
if str(checkoutRequest.userId) != str(ctx.user.id):
|
|
||||||
raise HTTPException(status_code=403, detail="Users can only load credit to their own account")
|
|
||||||
if not _isMemberOfMandate(ctx, targetMandateId):
|
|
||||||
raise HTTPException(status_code=403, detail="User is not a member of this mandate")
|
|
||||||
elif billingModel == BillingModelEnum.PREPAY_MANDATE:
|
|
||||||
if not _isAdminOfMandate(ctx, targetMandateId):
|
|
||||||
raise HTTPException(status_code=403, detail="Mandate admin role required to load mandate credit")
|
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Cannot add credit to {billingModel.value} billing model")
|
|
||||||
|
|
||||||
from modules.serviceCenter.services.serviceBilling.stripeCheckout import create_checkout_session
|
from modules.serviceCenter.services.serviceBilling.stripeCheckout import create_checkout_session
|
||||||
redirect_url = create_checkout_session(
|
redirect_url = create_checkout_session(
|
||||||
|
|
@ -944,19 +892,8 @@ 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 = parseBillingModelFromStoredValue(settings.get("billingModel"))
|
if not _isAdminOfMandate(ctx, mandate_id):
|
||||||
if billing_model == BillingModelEnum.PREPAY_USER:
|
raise HTTPException(status_code=403, detail="Mandate admin role required")
|
||||||
if not user_id:
|
|
||||||
raise HTTPException(status_code=400, detail="userId required for PREPAY_USER")
|
|
||||||
if str(user_id) != str(ctx.user.id):
|
|
||||||
raise HTTPException(status_code=403, detail="Users can only confirm their own payment sessions")
|
|
||||||
if not _isMemberOfMandate(ctx, mandate_id):
|
|
||||||
raise HTTPException(status_code=403, detail="User is not a member of this mandate")
|
|
||||||
elif billing_model == BillingModelEnum.PREPAY_MANDATE:
|
|
||||||
if not _isAdminOfMandate(ctx, mandate_id):
|
|
||||||
raise HTTPException(status_code=403, detail="Mandate admin role required")
|
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Cannot add credit to {billing_model.value}")
|
|
||||||
|
|
||||||
root_billing_interface = _getRootInterface()
|
root_billing_interface = _getRootInterface()
|
||||||
return _creditStripeSessionIfNeeded(root_billing_interface, session_dict, eventId=None)
|
return _creditStripeSessionIfNeeded(root_billing_interface, session_dict, eventId=None)
|
||||||
|
|
@ -1167,6 +1104,12 @@ def _handleSubscriptionCheckoutCompleted(session, eventId: str) -> None:
|
||||||
updatedSub = subInterface.getById(subscriptionRecordId)
|
updatedSub = subInterface.getById(subscriptionRecordId)
|
||||||
_notifySubscriptionChange(mandateId, "activated", plan, subscriptionRecord=updatedSub, platformUrl=platformUrl)
|
_notifySubscriptionChange(mandateId, "activated", plan, subscriptionRecord=updatedSub, platformUrl=platformUrl)
|
||||||
|
|
||||||
|
try:
|
||||||
|
billingIf = _getRootInterface()
|
||||||
|
billingIf.creditSubscriptionBudget(mandateId, planKey, periodLabel="Erstaktivierung")
|
||||||
|
except Exception as ex:
|
||||||
|
logger.error("creditSubscriptionBudget on activation failed: %s", ex)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Checkout completed: sub=%s -> %s, mandate=%s, plan=%s",
|
"Checkout completed: sub=%s -> %s, mandate=%s, plan=%s",
|
||||||
subscriptionRecordId, toStatus.value, mandateId, planKey,
|
subscriptionRecordId, toStatus.value, mandateId, planKey,
|
||||||
|
|
@ -1186,7 +1129,8 @@ def _handleSubscriptionWebhook(event) -> None:
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
obj = event.data.object
|
obj = event.data.object
|
||||||
stripeSubId = obj.get("id") if event.type.startswith("customer.subscription") else obj.get("subscription")
|
rawSub = obj.get("id") if event.type.startswith("customer.subscription") else obj.get("subscription")
|
||||||
|
stripeSubId = rawSub.get("id") if isinstance(rawSub, dict) else rawSub
|
||||||
if not stripeSubId:
|
if not stripeSubId:
|
||||||
logger.warning("Subscription webhook %s has no subscription ID", event.type)
|
logger.warning("Subscription webhook %s has no subscription ID", event.type)
|
||||||
return
|
return
|
||||||
|
|
@ -1224,9 +1168,14 @@ def _handleSubscriptionWebhook(event) -> None:
|
||||||
if stripeStatus == "active" and currentStatus == SubscriptionStatusEnum.SCHEDULED:
|
if stripeStatus == "active" and currentStatus == SubscriptionStatusEnum.SCHEDULED:
|
||||||
subInterface.transitionStatus(subId, SubscriptionStatusEnum.SCHEDULED, SubscriptionStatusEnum.ACTIVE)
|
subInterface.transitionStatus(subId, SubscriptionStatusEnum.SCHEDULED, SubscriptionStatusEnum.ACTIVE)
|
||||||
subService.invalidateCache(mandateId)
|
subService.invalidateCache(mandateId)
|
||||||
plan = _getPlan(sub.get("planKey", ""))
|
planKey = sub.get("planKey", "")
|
||||||
|
plan = _getPlan(planKey)
|
||||||
refreshedSub = subInterface.getById(subId)
|
refreshedSub = subInterface.getById(subId)
|
||||||
_notifySubscriptionChange(mandateId, "activated", plan, subscriptionRecord=refreshedSub, platformUrl=webhookPlatformUrl)
|
_notifySubscriptionChange(mandateId, "activated", plan, subscriptionRecord=refreshedSub, platformUrl=webhookPlatformUrl)
|
||||||
|
try:
|
||||||
|
_getRootInterface().creditSubscriptionBudget(mandateId, planKey, periodLabel="Erstaktivierung")
|
||||||
|
except Exception as ex:
|
||||||
|
logger.error("creditSubscriptionBudget SCHEDULED->ACTIVE failed: %s", ex)
|
||||||
logger.info("SCHEDULED -> ACTIVE for sub %s (mandate %s)", subId, mandateId)
|
logger.info("SCHEDULED -> ACTIVE for sub %s (mandate %s)", subId, mandateId)
|
||||||
|
|
||||||
elif stripeStatus == "active" and currentStatus == SubscriptionStatusEnum.PAST_DUE:
|
elif stripeStatus == "active" and currentStatus == SubscriptionStatusEnum.PAST_DUE:
|
||||||
|
|
@ -1292,6 +1241,25 @@ def _handleSubscriptionWebhook(event) -> None:
|
||||||
logger.error("Failed to notify about trial ending: %s", e)
|
logger.error("Failed to notify about trial ending: %s", e)
|
||||||
|
|
||||||
elif event.type == "invoice.paid":
|
elif event.type == "invoice.paid":
|
||||||
|
period_ts = obj.get("period_start")
|
||||||
|
periodLabel = ""
|
||||||
|
if period_ts:
|
||||||
|
period_start_at = datetime.fromtimestamp(int(period_ts), tz=timezone.utc)
|
||||||
|
periodLabel = period_start_at.strftime("%Y-%m-%d")
|
||||||
|
try:
|
||||||
|
billing_if = _getRootInterface()
|
||||||
|
billing_if.resetStorageBillingPeriod(mandateId, period_start_at)
|
||||||
|
billing_if.reconcileMandateStorageBilling(mandateId)
|
||||||
|
except Exception as ex:
|
||||||
|
logger.error("Storage billing on invoice.paid failed: %s", ex)
|
||||||
|
|
||||||
|
planKey = sub.get("planKey", "")
|
||||||
|
try:
|
||||||
|
billing_if = _getRootInterface()
|
||||||
|
billing_if.creditSubscriptionBudget(mandateId, planKey, periodLabel=periodLabel or "Periodenverlängerung")
|
||||||
|
except Exception as ex:
|
||||||
|
logger.error("creditSubscriptionBudget on invoice.paid failed: %s", ex)
|
||||||
|
|
||||||
logger.info("Invoice paid for sub %s (mandate %s)", subId, mandateId)
|
logger.info("Invoice paid for sub %s (mandate %s)", subId, mandateId)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -1321,7 +1289,6 @@ def getAccounts(
|
||||||
id=acc.get("id"),
|
id=acc.get("id"),
|
||||||
mandateId=acc.get("mandateId"),
|
mandateId=acc.get("mandateId"),
|
||||||
userId=acc.get("userId"),
|
userId=acc.get("userId"),
|
||||||
accountType=acc.get("accountType"),
|
|
||||||
balance=acc.get("balance", 0.0),
|
balance=acc.get("balance", 0.0),
|
||||||
warningThreshold=acc.get("warningThreshold", 0.0),
|
warningThreshold=acc.get("warningThreshold", 0.0),
|
||||||
enabled=acc.get("enabled", True)
|
enabled=acc.get("enabled", True)
|
||||||
|
|
@ -1404,6 +1371,31 @@ def getUsersForMandate(
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def _attachCreatedByUserNamesToTransactionRows(rows: List[Dict[str, Any]]) -> None:
|
||||||
|
"""Resolve createdByUserId to userName using root app interface (sysadmin transaction views)."""
|
||||||
|
try:
|
||||||
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
|
|
||||||
|
appRoot = getRootInterface()
|
||||||
|
userNames: Dict[str, str] = {}
|
||||||
|
for row in rows:
|
||||||
|
uid = row.get("createdByUserId")
|
||||||
|
if not uid:
|
||||||
|
row["userName"] = ""
|
||||||
|
continue
|
||||||
|
if uid not in userNames:
|
||||||
|
try:
|
||||||
|
u = appRoot.getUser(uid)
|
||||||
|
userNames[uid] = u.username if u else uid[:8]
|
||||||
|
except Exception:
|
||||||
|
userNames[uid] = uid[:8]
|
||||||
|
row["userName"] = userNames.get(uid, "")
|
||||||
|
except Exception:
|
||||||
|
for row in rows:
|
||||||
|
uid = row.get("createdByUserId")
|
||||||
|
row["userName"] = uid[:8] if uid else ""
|
||||||
|
|
||||||
|
|
||||||
def _enrichTransactionRows(transactions) -> List[Dict[str, Any]]:
|
def _enrichTransactionRows(transactions) -> List[Dict[str, Any]]:
|
||||||
"""Convert raw transaction dicts to enriched TransactionResponse rows with resolved usernames."""
|
"""Convert raw transaction dicts to enriched TransactionResponse rows with resolved usernames."""
|
||||||
result = []
|
result = []
|
||||||
|
|
@ -1421,27 +1413,11 @@ def _enrichTransactionRows(transactions) -> List[Dict[str, Any]]:
|
||||||
aicoreProvider=t.get("aicoreProvider"),
|
aicoreProvider=t.get("aicoreProvider"),
|
||||||
aicoreModel=t.get("aicoreModel"),
|
aicoreModel=t.get("aicoreModel"),
|
||||||
createdByUserId=t.get("createdByUserId"),
|
createdByUserId=t.get("createdByUserId"),
|
||||||
createdAt=t.get("_createdAt")
|
createdAt=t.get("sysCreatedAt")
|
||||||
)
|
)
|
||||||
result.append(row.model_dump())
|
result.append(row.model_dump())
|
||||||
|
|
||||||
try:
|
_attachCreatedByUserNamesToTransactionRows(result)
|
||||||
from modules.interfaces.interfaceDbUam import _getRootInterface as getUamRoot
|
|
||||||
uamInterface = getUamRoot()
|
|
||||||
userNames: Dict[str, str] = {}
|
|
||||||
for row in result:
|
|
||||||
uid = row.get("createdByUserId")
|
|
||||||
if uid and uid not in userNames:
|
|
||||||
try:
|
|
||||||
user = uamInterface.getUser(uid)
|
|
||||||
userNames[uid] = user.get("username", uid[:8]) if user else uid[:8]
|
|
||||||
except Exception:
|
|
||||||
userNames[uid] = uid[:8]
|
|
||||||
row["userName"] = userNames.get(uid, "") if uid else ""
|
|
||||||
except Exception:
|
|
||||||
for row in result:
|
|
||||||
row["userName"] = row.get("createdByUserId", "")[:8] if row.get("createdByUserId") else ""
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1465,28 +1441,11 @@ def _buildTransactionsList(ctx: RequestContext, targetMandateId: str) -> List[Di
|
||||||
aicoreProvider=t.get("aicoreProvider"),
|
aicoreProvider=t.get("aicoreProvider"),
|
||||||
aicoreModel=t.get("aicoreModel"),
|
aicoreModel=t.get("aicoreModel"),
|
||||||
createdByUserId=t.get("createdByUserId"),
|
createdByUserId=t.get("createdByUserId"),
|
||||||
createdAt=t.get("_createdAt")
|
createdAt=t.get("sysCreatedAt")
|
||||||
)
|
)
|
||||||
result.append(row.model_dump())
|
result.append(row.model_dump())
|
||||||
|
|
||||||
# Resolve user names
|
_attachCreatedByUserNamesToTransactionRows(result)
|
||||||
try:
|
|
||||||
from modules.interfaces.interfaceDbUam import _getRootInterface as getUamRoot
|
|
||||||
uamInterface = getUamRoot()
|
|
||||||
userNames: Dict[str, str] = {}
|
|
||||||
for row in result:
|
|
||||||
uid = row.get("createdByUserId")
|
|
||||||
if uid and uid not in userNames:
|
|
||||||
try:
|
|
||||||
user = uamInterface.getUser(uid)
|
|
||||||
userNames[uid] = user.get("username", uid[:8]) if user else uid[:8]
|
|
||||||
except Exception:
|
|
||||||
userNames[uid] = uid[:8]
|
|
||||||
row["userName"] = userNames.get(uid, "") if uid else ""
|
|
||||||
except Exception:
|
|
||||||
for row in result:
|
|
||||||
row["userName"] = row.get("createdByUserId", "")[:8] if row.get("createdByUserId") else ""
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1641,7 +1600,7 @@ def getMandateViewTransactions(
|
||||||
aicoreProvider=t.get("aicoreProvider"),
|
aicoreProvider=t.get("aicoreProvider"),
|
||||||
aicoreModel=t.get("aicoreModel"),
|
aicoreModel=t.get("aicoreModel"),
|
||||||
createdByUserId=t.get("createdByUserId"),
|
createdByUserId=t.get("createdByUserId"),
|
||||||
createdAt=t.get("_createdAt"),
|
createdAt=t.get("sysCreatedAt"),
|
||||||
mandateId=t.get("mandateId"),
|
mandateId=t.get("mandateId"),
|
||||||
mandateName=t.get("mandateName")
|
mandateName=t.get("mandateName")
|
||||||
))
|
))
|
||||||
|
|
@ -1796,7 +1755,7 @@ def getUserViewStatistics(
|
||||||
skippedNotDebit = 0
|
skippedNotDebit = 0
|
||||||
|
|
||||||
for t in allTransactions:
|
for t in allTransactions:
|
||||||
createdAt = t.get("_createdAt")
|
createdAt = t.get("sysCreatedAt")
|
||||||
if not createdAt:
|
if not createdAt:
|
||||||
skippedNoDate += 1
|
skippedNoDate += 1
|
||||||
continue
|
continue
|
||||||
|
|
@ -1972,7 +1931,7 @@ def getUserViewTransactions(
|
||||||
"aicoreProvider": t.get("aicoreProvider"),
|
"aicoreProvider": t.get("aicoreProvider"),
|
||||||
"aicoreModel": t.get("aicoreModel"),
|
"aicoreModel": t.get("aicoreModel"),
|
||||||
"createdByUserId": t.get("createdByUserId"),
|
"createdByUserId": t.get("createdByUserId"),
|
||||||
"createdAt": t.get("_createdAt"),
|
"createdAt": t.get("sysCreatedAt"),
|
||||||
"mandateId": t.get("mandateId"),
|
"mandateId": t.get("mandateId"),
|
||||||
"mandateName": t.get("mandateName"),
|
"mandateName": t.get("mandateName"),
|
||||||
"userId": t.get("userId"),
|
"userId": t.get("userId"),
|
||||||
|
|
@ -2069,7 +2028,7 @@ def getUserViewTransactionsFilterValues(
|
||||||
"aicoreProvider": t.get("aicoreProvider"),
|
"aicoreProvider": t.get("aicoreProvider"),
|
||||||
"aicoreModel": t.get("aicoreModel"),
|
"aicoreModel": t.get("aicoreModel"),
|
||||||
"createdByUserId": t.get("createdByUserId"),
|
"createdByUserId": t.get("createdByUserId"),
|
||||||
"createdAt": t.get("_createdAt"),
|
"createdAt": t.get("sysCreatedAt"),
|
||||||
"mandateId": t.get("mandateId"),
|
"mandateId": t.get("mandateId"),
|
||||||
"mandateName": t.get("mandateName"),
|
"mandateName": t.get("mandateName"),
|
||||||
"userId": t.get("userId"),
|
"userId": t.get("userId"),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
from fastapi import APIRouter, HTTPException, Depends, File, UploadFile, Form, Path, Request, status, Query, Response, Body
|
from fastapi import APIRouter, HTTPException, Depends, File, UploadFile, Form, Path, Request, status, Query, Response, Body, BackgroundTasks
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -8,6 +8,7 @@ import json
|
||||||
|
|
||||||
# Import auth module
|
# Import auth module
|
||||||
from modules.auth import limiter, getCurrentUser, getRequestContext, RequestContext
|
from modules.auth import limiter, getCurrentUser, getRequestContext, RequestContext
|
||||||
|
from modules.auth.authentication import _hasSysAdminRole
|
||||||
|
|
||||||
# Import interfaces
|
# Import interfaces
|
||||||
import modules.interfaces.interfaceDbManagement as interfaceDbManagement
|
import modules.interfaces.interfaceDbManagement as interfaceDbManagement
|
||||||
|
|
@ -40,13 +41,16 @@ async def _autoIndexFile(fileId: str, fileName: str, mimeType: str, user):
|
||||||
file_meta = mgmtInterface.getFile(fileId)
|
file_meta = mgmtInterface.getFile(fileId)
|
||||||
feature_instance_id = ""
|
feature_instance_id = ""
|
||||||
mandate_id = ""
|
mandate_id = ""
|
||||||
|
file_scope = "personal"
|
||||||
if file_meta:
|
if file_meta:
|
||||||
if isinstance(file_meta, dict):
|
if isinstance(file_meta, dict):
|
||||||
feature_instance_id = file_meta.get("featureInstanceId") or ""
|
feature_instance_id = file_meta.get("featureInstanceId") or ""
|
||||||
mandate_id = file_meta.get("mandateId") or ""
|
mandate_id = file_meta.get("mandateId") or ""
|
||||||
|
file_scope = file_meta.get("scope") or "personal"
|
||||||
else:
|
else:
|
||||||
feature_instance_id = getattr(file_meta, "featureInstanceId", None) or ""
|
feature_instance_id = getattr(file_meta, "featureInstanceId", None) or ""
|
||||||
mandate_id = getattr(file_meta, "mandateId", None) or ""
|
mandate_id = getattr(file_meta, "mandateId", None) or ""
|
||||||
|
file_scope = getattr(file_meta, "scope", None) or "personal"
|
||||||
|
|
||||||
logger.info(f"Auto-index starting for {fileName} ({len(rawBytes)} bytes, {mimeType})")
|
logger.info(f"Auto-index starting for {fileName} ({len(rawBytes)} bytes, {mimeType})")
|
||||||
|
|
||||||
|
|
@ -60,6 +64,7 @@ async def _autoIndexFile(fileId: str, fileName: str, mimeType: str, user):
|
||||||
userId=userId,
|
userId=userId,
|
||||||
featureInstanceId=str(feature_instance_id) if feature_instance_id else "",
|
featureInstanceId=str(feature_instance_id) if feature_instance_id else "",
|
||||||
mandateId=str(mandate_id) if mandate_id else "",
|
mandateId=str(mandate_id) if mandate_id else "",
|
||||||
|
scope=file_scope,
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Pre-scan complete for {fileName}: "
|
f"Pre-scan complete for {fileName}: "
|
||||||
|
|
@ -265,7 +270,7 @@ def get_file_filter_values(
|
||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
recordFilter = {"_createdBy": managementInterface.userId}
|
recordFilter = {"sysCreatedBy": managementInterface.userId}
|
||||||
values = managementInterface.db.getDistinctColumnValues(
|
values = managementInterface.db.getDistinctColumnValues(
|
||||||
FileItem, column, crossFilterPagination, recordFilter
|
FileItem, column, crossFilterPagination, recordFilter
|
||||||
)
|
)
|
||||||
|
|
@ -660,6 +665,145 @@ def batch_move_items(
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Scope & neutralize tagging endpoints (before /{fileId} catch-all) ─────────
|
||||||
|
|
||||||
|
@router.patch("/{fileId}/scope")
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
def updateFileScope(
|
||||||
|
request: Request,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
fileId: str = Path(..., description="ID of the file"),
|
||||||
|
scope: str = Body(..., embed=True),
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Update the scope of a file. Global scope requires sysAdmin."""
|
||||||
|
try:
|
||||||
|
validScopes = {"personal", "featureInstance", "mandate", "global"}
|
||||||
|
if scope not in validScopes:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid scope: {scope}. Must be one of {validScopes}")
|
||||||
|
|
||||||
|
if scope == "global" and not context.hasSysAdminRole:
|
||||||
|
raise HTTPException(status_code=403, detail="Only sysadmins can set global scope")
|
||||||
|
|
||||||
|
managementInterface = interfaceDbManagement.getInterface(
|
||||||
|
context.user,
|
||||||
|
mandateId=str(context.mandateId) if context.mandateId else None,
|
||||||
|
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
managementInterface.updateFile(fileId, {"scope": scope})
|
||||||
|
|
||||||
|
try:
|
||||||
|
from modules.interfaces.interfaceDbKnowledge import getInterface as getKnowledgeInterface
|
||||||
|
from modules.datamodels.datamodelKnowledge import FileContentIndex
|
||||||
|
knowledgeDb = getKnowledgeInterface()
|
||||||
|
indices = knowledgeDb.db.getRecordset(FileContentIndex, recordFilter={"id": fileId})
|
||||||
|
for idx in indices:
|
||||||
|
idxId = idx.get("id") if isinstance(idx, dict) else getattr(idx, "id", None)
|
||||||
|
if idxId:
|
||||||
|
knowledgeDb.db.recordModify(FileContentIndex, idxId, {"scope": scope})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to update FileContentIndex scope for file {fileId}: {e}")
|
||||||
|
|
||||||
|
fileMeta = managementInterface.getFile(fileId)
|
||||||
|
if fileMeta:
|
||||||
|
fn = fileMeta.fileName if hasattr(fileMeta, "fileName") else fileMeta.get("fileName", "")
|
||||||
|
mt = fileMeta.mimeType if hasattr(fileMeta, "mimeType") else fileMeta.get("mimeType", "")
|
||||||
|
|
||||||
|
async def _runReindexAfterScopeChange():
|
||||||
|
try:
|
||||||
|
await _autoIndexFile(fileId=fileId, fileName=fn, mimeType=mt, user=context.user)
|
||||||
|
except Exception as ex:
|
||||||
|
logger.warning("Re-index after scope change failed for %s: %s", fileId, ex)
|
||||||
|
|
||||||
|
background_tasks.add_task(_runReindexAfterScopeChange)
|
||||||
|
|
||||||
|
return {"fileId": fileId, "scope": scope, "updated": True}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating file scope: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{fileId}/neutralize")
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
def updateFileNeutralize(
|
||||||
|
request: Request,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
fileId: str = Path(..., description="ID of the file"),
|
||||||
|
neutralize: bool = Body(..., embed=True),
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Toggle neutralization flag on a file.
|
||||||
|
|
||||||
|
FAILSAFE: When turning neutralize ON, the existing Knowledge Store index
|
||||||
|
and all content chunks are deleted SYNCHRONOUSLY before the response is
|
||||||
|
returned. The re-index happens in a background task. If re-indexing
|
||||||
|
fails the file simply has no index — no un-neutralized data can leak.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
managementInterface = interfaceDbManagement.getInterface(
|
||||||
|
context.user,
|
||||||
|
mandateId=str(context.mandateId) if context.mandateId else None,
|
||||||
|
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
managementInterface.updateFile(fileId, {"neutralize": neutralize})
|
||||||
|
|
||||||
|
from modules.interfaces.interfaceDbKnowledge import getInterface as getKnowledgeInterface
|
||||||
|
knowledgeDb = getKnowledgeInterface()
|
||||||
|
|
||||||
|
if neutralize:
|
||||||
|
# ── CRITICAL: purge existing (potentially un-neutralized) index
|
||||||
|
# This MUST succeed before the response is sent so that no stale
|
||||||
|
# raw-text chunks remain searchable while re-indexing runs.
|
||||||
|
try:
|
||||||
|
knowledgeDb.deleteFileContentIndex(fileId)
|
||||||
|
logger.info("Neutralize toggle ON: deleted index + chunks for file %s", fileId)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Neutralize toggle ON: FAILED to delete index for file %s: %s", fileId, e)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Could not purge existing index for neutralization — aborting toggle. Error: {e}",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Turning neutralize OFF: update metadata only; re-index will overwrite
|
||||||
|
try:
|
||||||
|
from modules.datamodels.datamodelKnowledge import FileContentIndex
|
||||||
|
indices = knowledgeDb.db.getRecordset(FileContentIndex, recordFilter={"id": fileId})
|
||||||
|
for idx in indices:
|
||||||
|
idxId = idx.get("id") if isinstance(idx, dict) else getattr(idx, "id", None)
|
||||||
|
if idxId:
|
||||||
|
knowledgeDb.db.recordModify(FileContentIndex, idxId, {
|
||||||
|
"neutralizationStatus": "original",
|
||||||
|
"isNeutralized": False,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to update FileContentIndex after neutralize-OFF for %s: %s", fileId, e)
|
||||||
|
|
||||||
|
# Background re-index (safe: if it fails, there is simply no index)
|
||||||
|
fileMeta = managementInterface.getFile(fileId)
|
||||||
|
if fileMeta:
|
||||||
|
fn = fileMeta.fileName if hasattr(fileMeta, "fileName") else fileMeta.get("fileName", "")
|
||||||
|
mt = fileMeta.mimeType if hasattr(fileMeta, "mimeType") else fileMeta.get("mimeType", "")
|
||||||
|
|
||||||
|
async def _runReindexAfterNeutralizeToggle():
|
||||||
|
try:
|
||||||
|
await _autoIndexFile(fileId=fileId, fileName=fn, mimeType=mt, user=context.user)
|
||||||
|
except Exception as ex:
|
||||||
|
logger.error("Re-index after neutralize toggle failed for %s: %s (file has NO index until next re-index)", fileId, ex)
|
||||||
|
|
||||||
|
background_tasks.add_task(_runReindexAfterNeutralizeToggle)
|
||||||
|
|
||||||
|
return {"fileId": fileId, "neutralize": neutralize, "updated": True}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating file neutralize flag: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
# ── File endpoints with path parameters (catch-all /{fileId}) ─────────────────
|
# ── File endpoints with path parameters (catch-all /{fileId}) ─────────────────
|
||||||
|
|
||||||
@router.get("/{fileId}", response_model=FileItem)
|
@router.get("/{fileId}", response_model=FileItem)
|
||||||
|
|
@ -728,6 +872,12 @@ def update_file(
|
||||||
detail=f"File with ID {fileId} not found"
|
detail=f"File with ID {fileId} not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if file_info.get("scope") == "global" and not _hasSysAdminRole(str(currentUser.id)):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Only sysadmins can set global scope",
|
||||||
|
)
|
||||||
|
|
||||||
# Check if user has access to the file using RBAC
|
# Check if user has access to the file using RBAC
|
||||||
if not managementInterface.checkRbacPermission(FileItem, "update", fileId):
|
if not managementInterface.checkRbacPermission(FileItem, "update", fileId):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
|
||||||
97
modules/routes/routeDataSources.py
Normal file
97
modules/routes/routeDataSources.py
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""PATCH endpoints for DataSource and FeatureDataSource scope/neutralize tagging."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends, Path, Request, Body
|
||||||
|
from modules.auth import limiter, getRequestContext, RequestContext
|
||||||
|
from modules.auth.authentication import _hasSysAdminRole
|
||||||
|
from modules.datamodels.datamodelDataSource import DataSource
|
||||||
|
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/api/datasources",
|
||||||
|
tags=["Data Sources"],
|
||||||
|
responses={
|
||||||
|
404: {"description": "Not found"},
|
||||||
|
400: {"description": "Bad request"},
|
||||||
|
401: {"description": "Unauthorized"},
|
||||||
|
403: {"description": "Forbidden"},
|
||||||
|
500: {"description": "Internal server error"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
_VALID_SCOPES = {"personal", "featureInstance", "mandate", "global"}
|
||||||
|
|
||||||
|
|
||||||
|
def _findSourceRecord(db, sourceId: str):
|
||||||
|
"""Look up a source by ID, checking DataSource first, then FeatureDataSource."""
|
||||||
|
rec = db.getRecord(DataSource, sourceId)
|
||||||
|
if rec:
|
||||||
|
return rec, DataSource
|
||||||
|
rec = db.getRecord(FeatureDataSource, sourceId)
|
||||||
|
if rec:
|
||||||
|
return rec, FeatureDataSource
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{sourceId}/scope")
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
def _updateDataSourceScope(
|
||||||
|
request: Request,
|
||||||
|
sourceId: str = Path(..., description="ID of the DataSource or FeatureDataSource"),
|
||||||
|
scope: str = Body(..., embed=True),
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Update the scope of a DataSource or FeatureDataSource. Global scope requires sysAdmin."""
|
||||||
|
if scope not in _VALID_SCOPES:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid scope: {scope}. Must be one of {_VALID_SCOPES}")
|
||||||
|
|
||||||
|
if scope == "global" and not _hasSysAdminRole(context.user):
|
||||||
|
raise HTTPException(status_code=403, detail="Only sysadmins can set global scope")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
|
rootIf = getRootInterface()
|
||||||
|
rec, model = _findSourceRecord(rootIf.db, sourceId)
|
||||||
|
if not rec:
|
||||||
|
raise HTTPException(status_code=404, detail=f"DataSource {sourceId} not found")
|
||||||
|
|
||||||
|
rootIf.db.recordModify(model, sourceId, {"scope": scope})
|
||||||
|
logger.info("Updated scope=%s for %s %s", scope, model.__name__, sourceId)
|
||||||
|
return {"sourceId": sourceId, "scope": scope, "updated": True}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error updating datasource scope: %s", e)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{sourceId}/neutralize")
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
def _updateDataSourceNeutralize(
|
||||||
|
request: Request,
|
||||||
|
sourceId: str = Path(..., description="ID of the DataSource or FeatureDataSource"),
|
||||||
|
neutralize: bool = Body(..., embed=True),
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Toggle the neutralization flag on a DataSource or FeatureDataSource."""
|
||||||
|
try:
|
||||||
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
|
rootIf = getRootInterface()
|
||||||
|
rec, model = _findSourceRecord(rootIf.db, sourceId)
|
||||||
|
if not rec:
|
||||||
|
raise HTTPException(status_code=404, detail=f"DataSource {sourceId} not found")
|
||||||
|
|
||||||
|
rootIf.db.recordModify(model, sourceId, {"neutralize": neutralize})
|
||||||
|
logger.info("Updated neutralize=%s for %s %s", neutralize, model.__name__, sourceId)
|
||||||
|
return {"sourceId": sourceId, "neutralize": neutralize, "updated": True}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error updating datasource neutralize: %s", e)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
@ -639,14 +639,17 @@ def create_user(
|
||||||
|
|
||||||
# MULTI-TENANT: Add user to current mandate via UserMandate with default "user" role
|
# MULTI-TENANT: Add user to current mandate via UserMandate with default "user" role
|
||||||
if context.mandateId:
|
if context.mandateId:
|
||||||
# Get "user" role ID
|
|
||||||
userRole = appInterface.getRoleByLabel("user")
|
userRole = appInterface.getRoleByLabel("user")
|
||||||
roleIds = [str(userRole.id)] if userRole else []
|
if not userRole:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="No 'user' role found in system — cannot assign user to mandate"
|
||||||
|
)
|
||||||
|
|
||||||
appInterface.createUserMandate(
|
appInterface.createUserMandate(
|
||||||
userId=str(newUser.id),
|
userId=str(newUser.id),
|
||||||
mandateId=str(context.mandateId),
|
mandateId=str(context.mandateId),
|
||||||
roleIds=roleIds
|
roleIds=[str(userRole.id)]
|
||||||
)
|
)
|
||||||
logger.info(f"Created UserMandate for user {newUser.id} in mandate {context.mandateId}")
|
logger.info(f"Created UserMandate for user {newUser.id} in mandate {context.mandateId}")
|
||||||
|
|
||||||
|
|
@ -917,30 +920,29 @@ def send_password_link(
|
||||||
expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24"))
|
expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from modules.serviceHub import Services
|
from modules.routes.routeSecurityLocal import _buildAuthEmailHtml, _sendAuthEmail
|
||||||
services = Services(targetUser)
|
|
||||||
|
|
||||||
emailSubject = "PowerOn - Passwort setzen"
|
emailSubject = "PowerOn - Passwort setzen"
|
||||||
emailBody = f"""
|
emailHtml = _buildAuthEmailHtml(
|
||||||
Hallo {targetUser.fullName or targetUser.username},
|
greeting=f"Hallo {targetUser.fullName or targetUser.username}",
|
||||||
|
bodyLines=[
|
||||||
|
"Ein Administrator hat einen Link zum Setzen Ihres Passworts angefordert.",
|
||||||
|
"",
|
||||||
|
f"Ihr Benutzername: {targetUser.username}",
|
||||||
|
"",
|
||||||
|
"Klicken Sie auf die Schaltfläche, um Ihr Passwort zu setzen:",
|
||||||
|
],
|
||||||
|
buttonText="Passwort setzen",
|
||||||
|
buttonUrl=magicLink,
|
||||||
|
footerText=f"Dieser Link ist {expiryHours} Stunden gültig. Falls Sie diese Anforderung nicht erwartet haben, kontaktieren Sie bitte Ihren Administrator.",
|
||||||
|
)
|
||||||
|
|
||||||
Ein Administrator hat einen Link zum Setzen Ihres Passworts angefordert.
|
emailSent = _sendAuthEmail(
|
||||||
|
|
||||||
Ihr Benutzername: {targetUser.username}
|
|
||||||
|
|
||||||
Klicken Sie auf den folgenden Link, um Ihr Passwort zu setzen:
|
|
||||||
{magicLink}
|
|
||||||
|
|
||||||
Dieser Link ist {expiryHours} Stunden gültig.
|
|
||||||
|
|
||||||
Falls Sie diese Anforderung nicht erwartet haben, kontaktieren Sie bitte Ihren Administrator.
|
|
||||||
"""
|
|
||||||
|
|
||||||
emailSent = services.messaging.sendEmailDirect(
|
|
||||||
recipient=targetUser.email,
|
recipient=targetUser.email,
|
||||||
subject=emailSubject,
|
subject=emailSubject,
|
||||||
message=emailBody,
|
message="",
|
||||||
userId=str(targetUser.id)
|
userId=str(targetUser.id),
|
||||||
|
htmlOverride=emailHtml,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not emailSent:
|
if not emailSent:
|
||||||
|
|
|
||||||
|
|
@ -292,37 +292,24 @@ def create_invitation(
|
||||||
emailConnector = ConnectorMessagingEmail()
|
emailConnector = ConnectorMessagingEmail()
|
||||||
if instance_label:
|
if instance_label:
|
||||||
emailSubject = f"Einladung zur Feature-Instanz {instance_label}"
|
emailSubject = f"Einladung zur Feature-Instanz {instance_label}"
|
||||||
invite_text = f"der Feature-Instanz <strong>{instance_label}</strong> (Mandant: {mandateName}) beizutreten"
|
invite_desc = f"der Feature-Instanz «{instance_label}» (Mandant: {mandateName}) beizutreten"
|
||||||
else:
|
else:
|
||||||
emailSubject = f"Einladung zu {mandateName}"
|
emailSubject = f"Einladung zu {mandateName}"
|
||||||
invite_text = f"dem Mandanten <strong>{mandateName}</strong> beizutreten"
|
invite_desc = f"dem Mandanten «{mandateName}» beizutreten"
|
||||||
emailBody = f"""
|
|
||||||
<html>
|
from modules.routes.routeSecurityLocal import _buildAuthEmailHtml
|
||||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
emailBody = _buildAuthEmailHtml(
|
||||||
<h2>Sie wurden eingeladen!</h2>
|
greeting=f"Hallo {display_name}",
|
||||||
<p>Hallo <strong>{display_name}</strong>,</p>
|
bodyLines=[
|
||||||
<p>Sie wurden eingeladen, {invite_text}.</p>
|
f"Sie wurden eingeladen, {invite_desc}.",
|
||||||
<p>Klicken Sie auf den folgenden Link, um die Einladung anzunehmen:</p>
|
"",
|
||||||
<p style="margin: 20px 0;">
|
"Klicken Sie auf die Schaltfläche, um die Einladung anzunehmen:",
|
||||||
<a href="{inviteUrl}" style="background-color: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px;">
|
],
|
||||||
Einladung annehmen
|
buttonText="Einladung annehmen",
|
||||||
</a>
|
buttonUrl=inviteUrl,
|
||||||
</p>
|
footerText=f"Diese Einladung ist {data.expiresInHours} Stunden gültig.",
|
||||||
<p style="color: #666; font-size: 0.9em;">
|
)
|
||||||
Oder kopieren Sie diesen Link in Ihren Browser:<br>
|
|
||||||
<code>{inviteUrl}</code>
|
|
||||||
</p>
|
|
||||||
<p style="color: #666; font-size: 0.9em;">
|
|
||||||
Diese Einladung ist {data.expiresInHours} Stunden gültig.
|
|
||||||
</p>
|
|
||||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
|
||||||
<p style="color: #999; font-size: 0.8em;">
|
|
||||||
Diese E-Mail wurde automatisch von PowerOn gesendet.
|
|
||||||
</p>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
|
|
||||||
emailConnector.send(
|
emailConnector.send(
|
||||||
recipient=email_val,
|
recipient=email_val,
|
||||||
subject=emailSubject,
|
subject=emailSubject,
|
||||||
|
|
@ -376,6 +363,8 @@ def create_invitation(
|
||||||
f"to {target_desc}, expires in {data.expiresInHours}h"
|
f"to {target_desc}, expires in {data.expiresInHours}h"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Invitation extends PowerOnModel: recordCreate/_saveRecord set sysCreatedAt and sysCreatedBy automatically.
|
||||||
|
# API response uses createdAt/createdBy; map from the system fields (no separate createdAt column on model).
|
||||||
return InvitationResponse(
|
return InvitationResponse(
|
||||||
id=str(createdRecord.get("id")),
|
id=str(createdRecord.get("id")),
|
||||||
token=str(createdRecord.get("token")),
|
token=str(createdRecord.get("token")),
|
||||||
|
|
@ -384,8 +373,8 @@ def create_invitation(
|
||||||
roleIds=createdRecord.get("roleIds", []),
|
roleIds=createdRecord.get("roleIds", []),
|
||||||
targetUsername=createdRecord.get("targetUsername"),
|
targetUsername=createdRecord.get("targetUsername"),
|
||||||
email=createdRecord.get("email"),
|
email=createdRecord.get("email"),
|
||||||
createdBy=str(createdRecord.get("createdBy")),
|
createdBy=str(createdRecord["sysCreatedBy"]),
|
||||||
createdAt=createdRecord.get("createdAt"),
|
createdAt=float(createdRecord["sysCreatedAt"]),
|
||||||
expiresAt=createdRecord.get("expiresAt"),
|
expiresAt=createdRecord.get("expiresAt"),
|
||||||
usedBy=createdRecord.get("usedBy"),
|
usedBy=createdRecord.get("usedBy"),
|
||||||
usedAt=createdRecord.get("usedAt"),
|
usedAt=createdRecord.get("usedAt"),
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,7 @@ async def auth_login_callback(
|
||||||
user_info = user_info_response.json()
|
user_info = user_info_response.json()
|
||||||
|
|
||||||
rootInterface = getRootInterface()
|
rootInterface = getRootInterface()
|
||||||
|
isNewUser = False
|
||||||
user = rootInterface.getUserByUsername(user_info.get("email"))
|
user = rootInterface.getUserByUsername(user_info.get("email"))
|
||||||
if not user:
|
if not user:
|
||||||
user = rootInterface.createUser(
|
user = rootInterface.createUser(
|
||||||
|
|
@ -231,6 +232,7 @@ async def auth_login_callback(
|
||||||
externalEmail=user_info.get("email"),
|
externalEmail=user_info.get("email"),
|
||||||
addExternalIdentityConnection=False,
|
addExternalIdentityConnection=False,
|
||||||
)
|
)
|
||||||
|
isNewUser = True
|
||||||
|
|
||||||
jwt_token_data = {
|
jwt_token_data = {
|
||||||
"sub": user.username,
|
"sub": user.username,
|
||||||
|
|
@ -257,6 +259,13 @@ async def auth_login_callback(
|
||||||
)
|
)
|
||||||
appInterface = getInterface(user)
|
appInterface = getInterface(user)
|
||||||
appInterface.saveAccessToken(token)
|
appInterface.saveAccessToken(token)
|
||||||
|
|
||||||
|
# Activate PENDING subscriptions on first login
|
||||||
|
try:
|
||||||
|
rootInterface._activatePendingSubscriptions(str(user.id))
|
||||||
|
except Exception as subErr:
|
||||||
|
logger.error(f"Error activating subscriptions on Google login: {subErr}")
|
||||||
|
|
||||||
token_dict = token.model_dump()
|
token_dict = token.model_dump()
|
||||||
|
|
||||||
html_response = HTMLResponse(
|
html_response = HTMLResponse(
|
||||||
|
|
@ -268,7 +277,8 @@ async def auth_login_callback(
|
||||||
if (window.opener) {{
|
if (window.opener) {{
|
||||||
window.opener.postMessage({{
|
window.opener.postMessage({{
|
||||||
type: 'google_auth_success',
|
type: 'google_auth_success',
|
||||||
token_data: {json.dumps(token_dict)}
|
token_data: {json.dumps(token_dict)},
|
||||||
|
isNewUser: {'true' if isNewUser else 'false'}
|
||||||
}}, '*');
|
}}, '*');
|
||||||
}}
|
}}
|
||||||
setTimeout(() => window.close(), 1000);
|
setTimeout(() => window.close(), 1000);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
Routes for local security and authentication.
|
Routes for local security and authentication.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, status, Depends, Request, Response, Body
|
from fastapi import APIRouter, HTTPException, status, Depends, Request, Response, Body, Path
|
||||||
from fastapi.security import OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
|
|
@ -14,7 +14,7 @@ import uuid
|
||||||
from jose import jwt
|
from jose import jwt
|
||||||
|
|
||||||
# Import auth modules
|
# Import auth modules
|
||||||
from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM
|
from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM, getRequestContext, RequestContext
|
||||||
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
|
||||||
|
|
@ -26,36 +26,122 @@ from modules.shared.timeUtils import getUtcTimestamp
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _sendAuthEmail(recipient: str, subject: str, message: str, userId: str = None) -> bool:
|
def _buildAuthEmailHtml(
|
||||||
|
greeting: str,
|
||||||
|
bodyLines: list,
|
||||||
|
buttonText: str = None,
|
||||||
|
buttonUrl: str = None,
|
||||||
|
footerText: str = None,
|
||||||
|
) -> str:
|
||||||
|
"""Build a branded HTML email for authentication flows.
|
||||||
|
|
||||||
|
Uses the same visual design as notifyMandateAdmins._renderHtmlEmail
|
||||||
|
(dark header, clean body, operator footer).
|
||||||
|
"""
|
||||||
|
import html as _html
|
||||||
|
|
||||||
|
paragraphsHtml = ""
|
||||||
|
for line in bodyLines:
|
||||||
|
if line == "":
|
||||||
|
paragraphsHtml += '<p style="margin: 0 0 14px 0;"> </p>\n'
|
||||||
|
else:
|
||||||
|
escaped = _html.escape(str(line))
|
||||||
|
paragraphsHtml += f'<p style="margin: 0 0 14px 0; color: #333333;">{escaped}</p>\n'
|
||||||
|
|
||||||
|
buttonBlock = ""
|
||||||
|
if buttonText and buttonUrl:
|
||||||
|
buttonBlock = f'''<div style="text-align: center; margin: 24px 0 8px 0;">
|
||||||
|
<a href="{_html.escape(buttonUrl)}"
|
||||||
|
style="display: inline-block; background-color: #2563eb; color: #ffffff;
|
||||||
|
font-size: 15px; font-weight: 600; text-decoration: none;
|
||||||
|
padding: 12px 32px; border-radius: 6px; mso-padding-alt: 0;">
|
||||||
|
{_html.escape(buttonText)}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p style="margin: 8px 0 0 0; font-size: 12px; color: #9ca3af; word-break: break-all; text-align: center;">
|
||||||
|
{_html.escape(buttonUrl)}
|
||||||
|
</p>'''
|
||||||
|
|
||||||
|
footerNote = ""
|
||||||
|
if footerText:
|
||||||
|
footerNote = f'<p style="margin: 16px 0 0 0; font-size: 13px; color: #888888;">{_html.escape(footerText)}</p>\n'
|
||||||
|
|
||||||
|
operatorLine = ""
|
||||||
|
try:
|
||||||
|
from modules.shared.configuration import APP_CONFIG
|
||||||
|
parts = [p for p in [
|
||||||
|
APP_CONFIG.get("Operator_CompanyName", ""),
|
||||||
|
APP_CONFIG.get("Operator_Address", ""),
|
||||||
|
APP_CONFIG.get("Operator_VatNumber", ""),
|
||||||
|
] if p]
|
||||||
|
if parts:
|
||||||
|
operatorLine = (
|
||||||
|
f'<p style="margin: 4px 0 0 0; font-size: 11px; color: #b0b0b0; text-align: center;">'
|
||||||
|
f'{_html.escape(" | ".join(parts))}</p>\n'
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return f'''<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
|
||||||
|
<body style="margin: 0; padding: 0; background-color: #f4f4f7; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f7; padding: 32px 16px;">
|
||||||
|
<tr><td align="center">
|
||||||
|
<table role="presentation" width="560" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08);">
|
||||||
|
<!-- Header -->
|
||||||
|
<tr><td style="background-color: #1a1a2e; padding: 24px 32px;">
|
||||||
|
<h1 style="margin: 0; font-size: 18px; font-weight: 600; color: #ffffff;">PowerOn</h1>
|
||||||
|
</td></tr>
|
||||||
|
<!-- Body -->
|
||||||
|
<tr><td style="padding: 32px;">
|
||||||
|
<h2 style="margin: 0 0 20px 0; font-size: 20px; font-weight: 600; color: #1a1a2e;">{_html.escape(greeting)}</h2>
|
||||||
|
<div style="font-size: 15px; line-height: 1.6;">
|
||||||
|
{paragraphsHtml}
|
||||||
|
{buttonBlock}
|
||||||
|
</div>
|
||||||
|
{footerNote}
|
||||||
|
</td></tr>
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr><td style="padding: 16px 32px; background-color: #f9fafb; border-top: 1px solid #e5e7eb;">
|
||||||
|
<p style="margin: 0; font-size: 12px; color: #9ca3af; text-align: center;">
|
||||||
|
Diese E-Mail wurde automatisch von PowerOn versendet.
|
||||||
|
</p>
|
||||||
|
{operatorLine}
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>'''
|
||||||
|
|
||||||
|
|
||||||
|
def _sendAuthEmail(recipient: str, subject: str, message: str, userId: str = None, htmlOverride: str = None) -> bool:
|
||||||
"""
|
"""
|
||||||
Send authentication-related email directly without requiring full Services initialization.
|
Send authentication-related email directly without requiring full Services initialization.
|
||||||
Used for registration, password reset, and other auth flows.
|
Used for registration, password reset, and other auth flows.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
recipient: Email address
|
recipient: Email address
|
||||||
subject: Email subject
|
subject: Email subject
|
||||||
message: Plain text message (will be converted to HTML)
|
message: Plain text fallback (ignored when htmlOverride is given)
|
||||||
userId: Optional user ID for logging
|
userId: Optional user ID for logging
|
||||||
|
htmlOverride: Pre-built branded HTML (from _buildAuthEmailHtml)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if email was sent successfully
|
bool: True if email was sent successfully
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
import html
|
|
||||||
from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface
|
from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface
|
||||||
from modules.datamodels.datamodelMessaging import MessagingChannel
|
from modules.datamodels.datamodelMessaging import MessagingChannel
|
||||||
|
|
||||||
# Convert plain text to simple HTML
|
htmlMessage = htmlOverride
|
||||||
escaped = html.escape(message)
|
if not htmlMessage:
|
||||||
escaped = escaped.replace('\n', '<br>\n')
|
import html
|
||||||
htmlMessage = f"""<!DOCTYPE html>
|
escaped = html.escape(message)
|
||||||
<html>
|
escaped = escaped.replace('\n', '<br>\n')
|
||||||
<head><meta charset="utf-8"></head>
|
htmlMessage = f'<!DOCTYPE html><html><head><meta charset="utf-8"></head><body style="font-family: Arial, sans-serif; line-height: 1.6;">{escaped}</body></html>'
|
||||||
<body style="font-family: Arial, sans-serif; line-height: 1.6;">
|
|
||||||
{escaped}
|
|
||||||
</body>
|
|
||||||
</html>"""
|
|
||||||
|
|
||||||
messagingInterface = getMessagingInterface()
|
messagingInterface = getMessagingInterface()
|
||||||
success = messagingInterface.send(
|
success = messagingInterface.send(
|
||||||
channel=MessagingChannel.EMAIL,
|
channel=MessagingChannel.EMAIL,
|
||||||
|
|
@ -63,12 +149,12 @@ def _sendAuthEmail(recipient: str, subject: str, message: str, userId: str = Non
|
||||||
subject=subject,
|
subject=subject,
|
||||||
message=htmlMessage
|
message=htmlMessage
|
||||||
)
|
)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
logger.info(f"Auth email sent successfully to {recipient} (userId: {userId})")
|
logger.info(f"Auth email sent successfully to {recipient} (userId: {userId})")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Failed to send auth email to {recipient} (userId: {userId})")
|
logger.warning(f"Failed to send auth email to {recipient} (userId: {userId})")
|
||||||
|
|
||||||
return success
|
return success
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error sending auth email to {recipient}: {str(e)}", exc_info=True)
|
logger.error(f"Error sending auth email to {recipient}: {str(e)}", exc_info=True)
|
||||||
|
|
@ -87,6 +173,50 @@ router = APIRouter(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _ensureHomeMandate(rootInterface, user) -> None:
|
||||||
|
"""Ensure user has a Home mandate, but only if they have no mandate memberships
|
||||||
|
AND no pending invitations.
|
||||||
|
|
||||||
|
Invited users should NOT get a Home mandate — they join existing mandates via
|
||||||
|
invitation acceptance and can create their own later via onboarding.
|
||||||
|
"""
|
||||||
|
userId = str(user.id)
|
||||||
|
userMandates = rootInterface.getUserMandates(userId)
|
||||||
|
|
||||||
|
if userMandates:
|
||||||
|
for um in userMandates:
|
||||||
|
mandate = rootInterface.getMandate(um.mandateId)
|
||||||
|
if mandate and (mandate.name or "").startswith("Home ") and not mandate.isSystem:
|
||||||
|
return
|
||||||
|
logger.debug(f"User {user.username} has {len(userMandates)} mandate(s) but no Home — skipping auto-creation")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIf
|
||||||
|
appIf = _getRootIf()
|
||||||
|
normalizedEmail = (user.email or "").strip().lower() if user.email else None
|
||||||
|
pendingByUsername = appIf.getInvitationsByTargetUsername(user.username)
|
||||||
|
pendingByEmail = appIf.getInvitationsByEmail(normalizedEmail) if normalizedEmail else []
|
||||||
|
seenIds = set()
|
||||||
|
for inv in pendingByUsername + pendingByEmail:
|
||||||
|
if inv.id in seenIds:
|
||||||
|
continue
|
||||||
|
seenIds.add(inv.id)
|
||||||
|
if not inv.revokedAt and (inv.currentUses or 0) < (inv.maxUses or 1):
|
||||||
|
logger.info(f"User {user.username} has pending invitation(s) — skipping Home mandate creation")
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not check pending invitations for {user.username}: {e}")
|
||||||
|
|
||||||
|
homeMandateName = f"Home {user.username}"
|
||||||
|
rootInterface._provisionMandateForUser(
|
||||||
|
userId=userId,
|
||||||
|
mandateName=homeMandateName,
|
||||||
|
planKey="TRIAL_7D",
|
||||||
|
)
|
||||||
|
logger.info(f"Created Home mandate '{homeMandateName}' for user {user.username}")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login")
|
@router.post("/login")
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
def login(
|
def login(
|
||||||
|
|
@ -175,6 +305,21 @@ def login(
|
||||||
# Save access token
|
# Save access token
|
||||||
userInterface.saveAccessToken(token)
|
userInterface.saveAccessToken(token)
|
||||||
|
|
||||||
|
# Ensure user has a Home mandate (created on first login if missing)
|
||||||
|
try:
|
||||||
|
_ensureHomeMandate(rootInterface, user)
|
||||||
|
except Exception as homeErr:
|
||||||
|
logger.error(f"Error ensuring Home mandate for user {user.username}: {homeErr}")
|
||||||
|
|
||||||
|
# Activate PENDING subscriptions on first login (runs AFTER _ensureHomeMandate
|
||||||
|
# so that a freshly provisioned Home mandate subscription is also activated)
|
||||||
|
try:
|
||||||
|
activatedCount = rootInterface._activatePendingSubscriptions(str(user.id))
|
||||||
|
if activatedCount > 0:
|
||||||
|
logger.info(f"Activated {activatedCount} pending subscription(s) for user {user.username}")
|
||||||
|
except Exception as subErr:
|
||||||
|
logger.error(f"Error activating subscriptions on login: {subErr}")
|
||||||
|
|
||||||
# Log successful login (app log file + audit DB for traceability)
|
# Log successful login (app log file + audit DB for traceability)
|
||||||
logger.info("Login successful for username=%s (userId=%s)", formData.username, str(user.id))
|
logger.info("Login successful for username=%s (userId=%s)", formData.username, str(user.id))
|
||||||
try:
|
try:
|
||||||
|
|
@ -246,39 +391,34 @@ def login(
|
||||||
def register_user(
|
def register_user(
|
||||||
request: Request,
|
request: Request,
|
||||||
userData: User = Body(...),
|
userData: User = Body(...),
|
||||||
frontendUrl: str = Body(..., embed=True)
|
frontendUrl: str = Body(..., embed=True),
|
||||||
|
registrationType: str = Body("personal", embed=True),
|
||||||
|
companyName: str = Body(None, embed=True),
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Register a new local user (magic link based - no password required).
|
"""Register a new local user (magic link based - no password required).
|
||||||
|
|
||||||
|
Unified registration path: invited users skip Home mandate provisioning
|
||||||
|
(they join the inviting mandate instead). Non-invited users get a Home
|
||||||
|
mandate with TRIAL_7D. Company mandate creation is deferred to onboarding.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
userData: User data (username, email, fullName, language)
|
userData: User data (username, email, fullName, language)
|
||||||
frontendUrl: The frontend URL to use in magic link (REQUIRED - provided by frontend)
|
frontendUrl: The frontend URL to use in magic link (REQUIRED - provided by frontend)
|
||||||
|
registrationType: Kept for backward compat but ignored (company mandates via onboarding)
|
||||||
|
companyName: Kept for backward compat but ignored
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Get gateway interface with root privileges since this is a public endpoint
|
|
||||||
appInterface = getRootInterface()
|
appInterface = getRootInterface()
|
||||||
|
|
||||||
# Note: User registration does NOT require mandateId context
|
|
||||||
# Users are mandate-independent (Multi-Tenant Design)
|
|
||||||
# Mandate assignment happens via createUserMandate() after registration
|
|
||||||
|
|
||||||
# Frontend URL is required - no fallback
|
|
||||||
baseUrl = frontendUrl.rstrip("/")
|
baseUrl = frontendUrl.rstrip("/")
|
||||||
|
|
||||||
# Normalize email
|
|
||||||
normalizedEmail = userData.email.lower().strip() if userData.email else None
|
normalizedEmail = userData.email.lower().strip() if userData.email else None
|
||||||
|
|
||||||
# Note: Email can be shared across multiple users (different mandates)
|
|
||||||
# Username uniqueness is enforced in createUser() - that's the primary constraint
|
|
||||||
|
|
||||||
# Create user with local authentication (no password - magic link based)
|
|
||||||
user = appInterface.createUser(
|
user = appInterface.createUser(
|
||||||
username=userData.username,
|
username=userData.username,
|
||||||
password=None, # No password - will be set via magic link
|
password=None,
|
||||||
email=normalizedEmail,
|
email=normalizedEmail,
|
||||||
fullName=userData.fullName,
|
fullName=userData.fullName,
|
||||||
language=userData.language,
|
language=userData.language,
|
||||||
enabled=True, # Users are enabled by default (can login after setting password)
|
enabled=True,
|
||||||
authenticationAuthority=AuthAuthority.LOCAL
|
authenticationAuthority=AuthAuthority.LOCAL
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -288,6 +428,52 @@ def register_user(
|
||||||
detail="Failed to register user"
|
detail="Failed to register user"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check for pending invitations BEFORE provisioning.
|
||||||
|
# Search by both username AND email (email-only invitations have targetUsername=None).
|
||||||
|
hasPendingInvitations = False
|
||||||
|
validInvitations = []
|
||||||
|
try:
|
||||||
|
from modules.datamodels.datamodelInvitation import Invitation
|
||||||
|
|
||||||
|
currentTime = getUtcTimestamp()
|
||||||
|
pendingByUsername = appInterface.getInvitationsByTargetUsername(userData.username)
|
||||||
|
pendingByEmail = appInterface.getInvitationsByEmail(normalizedEmail) if normalizedEmail else []
|
||||||
|
|
||||||
|
seenIds = set()
|
||||||
|
allPending = pendingByUsername + pendingByEmail
|
||||||
|
for invitation in allPending:
|
||||||
|
if invitation.id in seenIds:
|
||||||
|
continue
|
||||||
|
seenIds.add(invitation.id)
|
||||||
|
if (invitation.expiresAt or 0) < currentTime:
|
||||||
|
continue
|
||||||
|
if invitation.revokedAt:
|
||||||
|
continue
|
||||||
|
if (invitation.currentUses or 0) >= (invitation.maxUses or 1):
|
||||||
|
continue
|
||||||
|
validInvitations.append(invitation)
|
||||||
|
|
||||||
|
hasPendingInvitations = len(validInvitations) > 0
|
||||||
|
except Exception as invErr:
|
||||||
|
logger.warning(f"Failed to check pending invitations: {invErr}")
|
||||||
|
|
||||||
|
# Only provision Home mandate if user has NO pending invitations.
|
||||||
|
# Invited users join existing mandates; they can create their own later via onboarding.
|
||||||
|
provisionResult = None
|
||||||
|
if not hasPendingInvitations:
|
||||||
|
try:
|
||||||
|
homeMandateName = f"Home {user.username}"
|
||||||
|
provisionResult = appInterface._provisionMandateForUser(
|
||||||
|
userId=str(user.id),
|
||||||
|
mandateName=homeMandateName,
|
||||||
|
planKey="TRIAL_7D",
|
||||||
|
)
|
||||||
|
logger.info(f"Provisioned Home mandate for user {user.id}: {provisionResult}")
|
||||||
|
except Exception as provErr:
|
||||||
|
logger.error(f"Error provisioning Home mandate for user {user.id}: {provErr}")
|
||||||
|
else:
|
||||||
|
logger.info(f"Skipping Home mandate for user {user.id} — has {len(validInvitations)} pending invitation(s)")
|
||||||
|
|
||||||
# Generate reset token for password setup
|
# Generate reset token for password setup
|
||||||
token, expires = appInterface.generateResetTokenAndExpiry()
|
token, expires = appInterface.generateResetTokenAndExpiry()
|
||||||
appInterface.setResetToken(user.id, token, expires, clearPassword=False)
|
appInterface.setResetToken(user.id, token, expires, clearPassword=False)
|
||||||
|
|
@ -298,57 +484,43 @@ def register_user(
|
||||||
expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24"))
|
expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24"))
|
||||||
|
|
||||||
emailSubject = "PowerOn Registrierung - Passwort setzen"
|
emailSubject = "PowerOn Registrierung - Passwort setzen"
|
||||||
emailBody = f"""Hallo {user.fullName or user.username},
|
emailHtml = _buildAuthEmailHtml(
|
||||||
|
greeting=f"Hallo {user.fullName or user.username}",
|
||||||
|
bodyLines=[
|
||||||
|
"Vielen Dank für Ihre Registrierung bei PowerOn.",
|
||||||
|
"",
|
||||||
|
f"Ihr Benutzername: {user.username}",
|
||||||
|
"",
|
||||||
|
"Klicken Sie auf die Schaltfläche, um Ihr Passwort zu setzen:",
|
||||||
|
],
|
||||||
|
buttonText="Passwort setzen",
|
||||||
|
buttonUrl=magicLink,
|
||||||
|
footerText=f"Dieser Link ist {expiryHours} Stunden gültig. Falls Sie sich nicht registriert haben, können Sie diese E-Mail ignorieren.",
|
||||||
|
)
|
||||||
|
|
||||||
Vielen Dank für Ihre Registrierung bei PowerOn.
|
|
||||||
|
|
||||||
Ihr Benutzername: {user.username}
|
|
||||||
|
|
||||||
Klicken Sie auf den folgenden Link, um Ihr Passwort zu setzen:
|
|
||||||
{magicLink}
|
|
||||||
|
|
||||||
Dieser Link ist {expiryHours} Stunden gültig.
|
|
||||||
|
|
||||||
Falls Sie sich nicht registriert haben, können Sie diese E-Mail ignorieren."""
|
|
||||||
|
|
||||||
emailSent = _sendAuthEmail(
|
emailSent = _sendAuthEmail(
|
||||||
recipient=user.email,
|
recipient=user.email,
|
||||||
subject=emailSubject,
|
subject=emailSubject,
|
||||||
message=emailBody,
|
message="",
|
||||||
userId=str(user.id)
|
userId=str(user.id),
|
||||||
|
htmlOverride=emailHtml,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not emailSent:
|
if not emailSent:
|
||||||
logger.warning(f"Failed to send registration email to {user.email}")
|
logger.warning(f"Failed to send registration email to {user.email}")
|
||||||
except Exception as emailErr:
|
except Exception as emailErr:
|
||||||
logger.error(f"Error sending registration email: {str(emailErr)}")
|
logger.error(f"Error sending registration email: {str(emailErr)}")
|
||||||
# Don't fail registration if email fails - user can request reset later
|
|
||||||
|
|
||||||
# Check for pending invitations and create notifications
|
# Create notifications for pending invitations
|
||||||
try:
|
for invitation in validInvitations:
|
||||||
from modules.datamodels.datamodelInvitation import Invitation
|
try:
|
||||||
from modules.routes.routeNotifications import createInvitationNotification
|
from modules.routes.routeNotifications import createInvitationNotification
|
||||||
from modules.datamodels.datamodelUam import Mandate
|
|
||||||
|
|
||||||
currentTime = getUtcTimestamp()
|
|
||||||
pendingInvitations = appInterface.getInvitationsByTargetUsername(userData.username)
|
|
||||||
|
|
||||||
for invitation in pendingInvitations:
|
|
||||||
# Skip expired, revoked, or fully used invitations
|
|
||||||
if (invitation.expiresAt or 0) < currentTime:
|
|
||||||
continue
|
|
||||||
if invitation.revokedAt:
|
|
||||||
continue
|
|
||||||
if (invitation.currentUses or 0) >= (invitation.maxUses or 1):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Get mandate name for notification using interface method
|
|
||||||
mandateId = invitation.mandateId
|
mandateId = invitation.mandateId
|
||||||
mandate = appInterface.getMandate(mandateId)
|
mandate = appInterface.getMandate(mandateId)
|
||||||
mandateName = (mandate.label or mandate.name) if mandate else "PowerOn"
|
mandateName = (mandate.label or mandate.name) if mandate else "PowerOn"
|
||||||
|
|
||||||
# Get inviter name
|
inviterId = invitation.sysCreatedBy
|
||||||
inviterId = invitation.createdBy
|
|
||||||
inviter = appInterface.getUser(inviterId) if inviterId else None
|
inviter = appInterface.getUser(inviterId) if inviterId else None
|
||||||
inviterName = (inviter.fullName or inviter.username) if inviter else "PowerOn"
|
inviterName = (inviter.fullName or inviter.username) if inviter else "PowerOn"
|
||||||
|
|
||||||
|
|
@ -359,14 +531,16 @@ Falls Sie sich nicht registriert haben, können Sie diese E-Mail ignorieren."""
|
||||||
inviterName=inviterName
|
inviterName=inviterName
|
||||||
)
|
)
|
||||||
logger.info(f"Created notification for new user {userData.username} for invitation {invitation.id}")
|
logger.info(f"Created notification for new user {userData.username} for invitation {invitation.id}")
|
||||||
|
except Exception as notifErr:
|
||||||
except Exception as notifErr:
|
logger.warning(f"Failed to create notification for invitation {invitation.id}: {notifErr}")
|
||||||
logger.warning(f"Failed to create notifications for pending invitations: {notifErr}")
|
|
||||||
# Don't fail registration if notification creation fails
|
|
||||||
|
|
||||||
return {
|
responseData = {
|
||||||
"message": "Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail für den Link zum Setzen Ihres Passworts."
|
"message": "Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail für den Link zum Setzen Ihres Passworts."
|
||||||
}
|
}
|
||||||
|
if provisionResult:
|
||||||
|
responseData["mandateId"] = provisionResult.get("mandateId")
|
||||||
|
responseData["hasInvitations"] = hasPendingInvitations
|
||||||
|
return responseData
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -611,24 +785,26 @@ def password_reset_request(
|
||||||
|
|
||||||
# Send email using dedicated auth email function
|
# Send email using dedicated auth email function
|
||||||
emailSubject = "PowerOn - Passwort zurücksetzen"
|
emailSubject = "PowerOn - Passwort zurücksetzen"
|
||||||
emailBody = f"""Hallo {user.fullName or user.username},
|
emailHtml = _buildAuthEmailHtml(
|
||||||
|
greeting=f"Hallo {user.fullName or user.username}",
|
||||||
|
bodyLines=[
|
||||||
|
"Sie haben eine Passwort-Zurücksetzung für Ihren PowerOn Account angefordert.",
|
||||||
|
"",
|
||||||
|
f"Benutzername: {user.username}",
|
||||||
|
"",
|
||||||
|
"Klicken Sie auf die Schaltfläche, um Ihr Passwort zurückzusetzen:",
|
||||||
|
],
|
||||||
|
buttonText="Passwort zurücksetzen",
|
||||||
|
buttonUrl=magicLink,
|
||||||
|
footerText=f"Dieser Link ist {expiryHours} Stunden gültig. Falls Sie diese Anforderung nicht gestellt haben, können Sie diese E-Mail ignorieren.",
|
||||||
|
)
|
||||||
|
|
||||||
Sie haben eine Passwort-Zurücksetzung für Ihren PowerOn Account angefordert.
|
|
||||||
|
|
||||||
Benutzername: {user.username}
|
|
||||||
|
|
||||||
Klicken Sie auf den folgenden Link, um Ihr Passwort zurückzusetzen:
|
|
||||||
{magicLink}
|
|
||||||
|
|
||||||
Dieser Link ist {expiryHours} Stunden gültig.
|
|
||||||
|
|
||||||
Falls Sie diese Anforderung nicht gestellt haben, können Sie diese E-Mail ignorieren."""
|
|
||||||
|
|
||||||
emailSent = _sendAuthEmail(
|
emailSent = _sendAuthEmail(
|
||||||
recipient=user.email,
|
recipient=user.email,
|
||||||
subject=emailSubject,
|
subject=emailSubject,
|
||||||
message=emailBody,
|
message="",
|
||||||
userId=str(user.id)
|
userId=str(user.id),
|
||||||
|
htmlOverride=emailHtml,
|
||||||
)
|
)
|
||||||
|
|
||||||
if emailSent:
|
if emailSent:
|
||||||
|
|
@ -652,6 +828,90 @@ Falls Sie diese Anforderung nicht gestellt haben, können Sie diese E-Mail ignor
|
||||||
"message": "Falls ein Konto mit diesem Benutzernamen existiert, wurde ein Reset-Link an die hinterlegte E-Mail-Adresse gesendet."
|
"message": "Falls ein Konto mit diesem Benutzernamen existiert, wurde ein Reset-Link an die hinterlegte E-Mail-Adresse gesendet."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@router.post("/onboarding")
|
||||||
|
@limiter.limit("5/minute")
|
||||||
|
def onboarding_provision(
|
||||||
|
request: Request,
|
||||||
|
currentUser: User = Depends(getCurrentUser),
|
||||||
|
companyName: str = Body(None, embed=True),
|
||||||
|
planKey: str = Body("TRIAL_7D", embed=True),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Post-login onboarding: create a mandate for the user.
|
||||||
|
|
||||||
|
Guard: user can only create a mandate if they are NOT already admin in any
|
||||||
|
non-system mandate. This prevents duplicate provisioning.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
|
||||||
|
from modules.datamodels.datamodelRbac import Role
|
||||||
|
|
||||||
|
appInterface = getRootInterface()
|
||||||
|
db = appInterface.db
|
||||||
|
userId = str(currentUser.id)
|
||||||
|
|
||||||
|
# Check if user already has admin role in a non-system mandate
|
||||||
|
userMandates = db.getRecordset(UserMandate, recordFilter={"userId": userId, "enabled": True})
|
||||||
|
hasAdminMandate = False
|
||||||
|
for um in userMandates:
|
||||||
|
mandateId = um.get("mandateId")
|
||||||
|
mandate = db.getRecordset(Mandate, recordFilter={"id": mandateId})
|
||||||
|
if mandate and mandate[0].get("isSystem"):
|
||||||
|
continue
|
||||||
|
umId = um.get("id")
|
||||||
|
umRoles = db.getRecordset(UserMandateRole, recordFilter={"userMandateId": umId})
|
||||||
|
for umRole in umRoles:
|
||||||
|
roleId = umRole.get("roleId")
|
||||||
|
roles = db.getRecordset(Role, recordFilter={"id": roleId})
|
||||||
|
for role in roles:
|
||||||
|
if "admin" in (role.get("roleLabel") or "").lower():
|
||||||
|
hasAdminMandate = True
|
||||||
|
break
|
||||||
|
if hasAdminMandate:
|
||||||
|
break
|
||||||
|
if hasAdminMandate:
|
||||||
|
break
|
||||||
|
|
||||||
|
if hasAdminMandate:
|
||||||
|
logger.info(f"Onboarding: user {currentUser.username} already has admin mandate — skipping provisioning")
|
||||||
|
return {
|
||||||
|
"message": "User already has an admin mandate",
|
||||||
|
"mandateId": None,
|
||||||
|
"alreadyProvisioned": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
mandateName = (companyName.strip() if companyName and companyName.strip()
|
||||||
|
else f"Home {currentUser.username}")
|
||||||
|
|
||||||
|
if planKey not in ("TRIAL_7D", "STANDARD_MONTHLY", "STANDARD_YEARLY"):
|
||||||
|
planKey = "TRIAL_7D"
|
||||||
|
|
||||||
|
result = appInterface._provisionMandateForUser(
|
||||||
|
userId=userId,
|
||||||
|
mandateName=mandateName,
|
||||||
|
planKey=planKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
activatedCount = appInterface._activatePendingSubscriptions(userId)
|
||||||
|
if activatedCount > 0:
|
||||||
|
logger.info(f"Activated {activatedCount} pending subscription(s) for user {currentUser.username} during onboarding")
|
||||||
|
except Exception as subErr:
|
||||||
|
logger.error(f"Error activating subscriptions during onboarding: {subErr}")
|
||||||
|
|
||||||
|
logger.info(f"Onboarding provision for {currentUser.username}: {result}")
|
||||||
|
return {
|
||||||
|
"message": "Mandate provisioned successfully",
|
||||||
|
"mandateId": result.get("mandateId") if result else None,
|
||||||
|
"alreadyProvisioned": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Onboarding provision failed: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/password-reset")
|
@router.post("/password-reset")
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
def password_reset(
|
def password_reset(
|
||||||
|
|
@ -710,3 +970,45 @@ def password_reset(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail="Passwort-Zurücksetzung fehlgeschlagen"
|
detail="Passwort-Zurücksetzung fehlgeschlagen"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Neutralization Mappings (user-level, view/delete)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@router.get("/neutralization-mappings")
|
||||||
|
@limiter.limit("60/minute")
|
||||||
|
def _getNeutralizationMappings(
|
||||||
|
request: Request,
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
):
|
||||||
|
"""List the current user's neutralization placeholder mappings."""
|
||||||
|
userId = str(context.user.id)
|
||||||
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
|
from modules.features.neutralization.datamodelFeatureNeutralizer import DataNeutralizerAttributes
|
||||||
|
rootIf = getRootInterface()
|
||||||
|
records = rootIf.db.getRecordset(DataNeutralizerAttributes, recordFilter={"userId": userId})
|
||||||
|
return {"mappings": records}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/neutralization-mappings/{mappingId}")
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
def _deleteNeutralizationMapping(
|
||||||
|
request: Request,
|
||||||
|
mappingId: str = Path(..., description="ID of the mapping to delete"),
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
):
|
||||||
|
"""Delete a specific neutralization mapping owned by the current user."""
|
||||||
|
userId = str(context.user.id)
|
||||||
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
|
from modules.features.neutralization.datamodelFeatureNeutralizer import DataNeutralizerAttributes
|
||||||
|
rootIf = getRootInterface()
|
||||||
|
records = rootIf.db.getRecordset(DataNeutralizerAttributes, recordFilter={"id": mappingId})
|
||||||
|
if not records:
|
||||||
|
raise HTTPException(status_code=404, detail="Mapping not found")
|
||||||
|
rec = records[0]
|
||||||
|
recUserId = rec.get("userId") if isinstance(rec, dict) else getattr(rec, "userId", None)
|
||||||
|
if recUserId != userId:
|
||||||
|
raise HTTPException(status_code=403, detail="Not your mapping")
|
||||||
|
rootIf.db.recordDelete(DataNeutralizerAttributes, mappingId)
|
||||||
|
return {"deleted": True, "id": mappingId}
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,12 @@
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
Feature Store routes.
|
Feature Store routes.
|
||||||
Allows users to self-activate features in the root mandate's shared instances.
|
Own Instance Pattern: Each activation creates a new FeatureInstance
|
||||||
|
in the user's explicit mandate. Supports Orphan Control.
|
||||||
Architecture: Shared Instance Pattern
|
|
||||||
- Each store feature has exactly 1 instance in the root mandate (created at bootstrap)
|
|
||||||
- Users activate by getting FeatureAccess + user-role on the shared instance
|
|
||||||
- Data isolation is guaranteed by read="m" (WHERE _createdBy = userId)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Request
|
from fastapi import APIRouter, HTTPException, Depends, Request
|
||||||
from typing import List, Dict, Any
|
from typing import List, Dict, Any, Optional
|
||||||
from fastapi import status
|
from fastapi import status
|
||||||
import logging
|
import logging
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
@ -19,8 +15,9 @@ from pydantic import BaseModel, Field
|
||||||
from modules.auth import limiter, getRequestContext, RequestContext
|
from modules.auth import limiter, getRequestContext, RequestContext
|
||||||
from modules.datamodels.datamodelFeatures import FeatureInstance
|
from modules.datamodels.datamodelFeatures import FeatureInstance
|
||||||
from modules.datamodels.datamodelMembership import FeatureAccess, FeatureAccessRole
|
from modules.datamodels.datamodelMembership import FeatureAccess, FeatureAccessRole
|
||||||
from modules.datamodels.datamodelRbac import AccessRuleContext
|
from modules.datamodels.datamodelRbac import AccessRuleContext, Role
|
||||||
from modules.datamodels.datamodelUam import Mandate
|
from modules.datamodels.datamodelUam import Mandate
|
||||||
|
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
|
||||||
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
|
||||||
|
|
@ -38,7 +35,15 @@ router = APIRouter(
|
||||||
|
|
||||||
class StoreActivateRequest(BaseModel):
|
class StoreActivateRequest(BaseModel):
|
||||||
"""Request model for activating a store feature."""
|
"""Request model for activating a store feature."""
|
||||||
featureCode: str = Field(..., description="Feature code to activate (e.g., 'automation')")
|
featureCode: str = Field(..., description="Feature code to activate")
|
||||||
|
mandateId: str = Field(..., description="Target mandate ID — always explicit, never optional")
|
||||||
|
|
||||||
|
|
||||||
|
class StoreDeactivateRequest(BaseModel):
|
||||||
|
"""Request model for deactivating a store feature."""
|
||||||
|
featureCode: str = Field(..., description="Feature code to deactivate")
|
||||||
|
mandateId: str = Field(..., description="Mandate ID")
|
||||||
|
instanceId: str = Field(..., description="FeatureInstance ID to deactivate")
|
||||||
|
|
||||||
|
|
||||||
class StoreFeatureResponse(BaseModel):
|
class StoreFeatureResponse(BaseModel):
|
||||||
|
|
@ -47,21 +52,12 @@ class StoreFeatureResponse(BaseModel):
|
||||||
label: Dict[str, str]
|
label: Dict[str, str]
|
||||||
icon: str
|
icon: str
|
||||||
description: Dict[str, str] = {}
|
description: Dict[str, str] = {}
|
||||||
isActive: bool
|
instances: List[Dict[str, Any]] = []
|
||||||
canActivate: bool
|
canActivate: bool
|
||||||
instanceId: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def _getRootMandateId(db) -> str | None:
|
|
||||||
"""Find the root mandate ID."""
|
|
||||||
mandates = db.getRecordset(Mandate, recordFilter={"name": "root", "isSystem": True})
|
|
||||||
if mandates:
|
|
||||||
return mandates[0].get("id")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _getStoreFeatures(catalogService) -> List[Dict[str, Any]]:
|
def _getStoreFeatures(catalogService) -> List[Dict[str, Any]]:
|
||||||
"""Get all features that are available in the store (have resource.store.* entries)."""
|
"""Get all features available in the store."""
|
||||||
resourceObjects = catalogService.getResourceObjects()
|
resourceObjects = catalogService.getResourceObjects()
|
||||||
storeFeatures = []
|
storeFeatures = []
|
||||||
for obj in resourceObjects:
|
for obj in resourceObjects:
|
||||||
|
|
@ -75,75 +71,153 @@ def _getStoreFeatures(catalogService) -> List[Dict[str, Any]]:
|
||||||
return storeFeatures
|
return storeFeatures
|
||||||
|
|
||||||
|
|
||||||
def _checkStorePermission(context: RequestContext, featureCode: str) -> bool:
|
def _isUserAdminInMandate(db, userId: str, mandateId: str) -> bool:
|
||||||
"""Check if user has RBAC permission to activate a store feature."""
|
"""Check if user has admin role in a mandate."""
|
||||||
if context.hasSysAdminRole:
|
userMandates = db.getRecordset(UserMandate, recordFilter={"userId": userId, "mandateId": mandateId, "enabled": True})
|
||||||
return True
|
if not userMandates:
|
||||||
|
return False
|
||||||
resourceItem = f"resource.store.{featureCode}"
|
umId = userMandates[0].get("id")
|
||||||
dbApp = getRootDbAppConnector()
|
umRoles = db.getRecordset(UserMandateRole, recordFilter={"userMandateId": umId})
|
||||||
rbacInstance = RbacClass(dbApp, dbApp=dbApp)
|
for umRole in umRoles:
|
||||||
permissions = rbacInstance.getUserPermissions(
|
roleId = umRole.get("roleId")
|
||||||
context.user,
|
roles = db.getRecordset(Role, recordFilter={"id": roleId})
|
||||||
AccessRuleContext.RESOURCE,
|
for role in roles:
|
||||||
resourceItem,
|
if "admin" in (role.get("roleLabel") or "").lower():
|
||||||
mandateId=str(context.mandateId) if context.mandateId else None,
|
return True
|
||||||
)
|
return False
|
||||||
return permissions.view
|
|
||||||
|
|
||||||
|
|
||||||
def _findSharedInstance(db, rootMandateId: str, featureCode: str) -> Dict[str, Any] | None:
|
def _getUserAdminMandateIds(db, userId: str) -> List[str]:
|
||||||
"""Find the shared instance for a feature in the root mandate."""
|
"""Get all mandate IDs where user is admin."""
|
||||||
instances = db.getRecordset(
|
userMandates = db.getRecordset(UserMandate, recordFilter={"userId": userId, "enabled": True})
|
||||||
FeatureInstance,
|
adminMandateIds = []
|
||||||
recordFilter={"mandateId": rootMandateId, "featureCode": featureCode}
|
for um in userMandates:
|
||||||
)
|
mandateId = um.get("mandateId")
|
||||||
return instances[0] if instances else None
|
mandate = db.getRecordset(Mandate, recordFilter={"id": mandateId})
|
||||||
|
if mandate and mandate[0].get("isSystem"):
|
||||||
|
continue
|
||||||
|
if _isUserAdminInMandate(db, userId, mandateId):
|
||||||
|
adminMandateIds.append(mandateId)
|
||||||
|
return adminMandateIds
|
||||||
|
|
||||||
|
|
||||||
def _getUserFeatureAccess(db, userId: str, instanceId: str) -> Dict[str, Any] | None:
|
def _getUserInstancesForFeature(db, userId: str, featureCode: str, mandateIds: List[str]) -> List[Dict[str, Any]]:
|
||||||
"""Check if user already has FeatureAccess for an instance."""
|
"""Get user's active instances for a feature across their mandates."""
|
||||||
accesses = db.getRecordset(
|
instances = []
|
||||||
FeatureAccess,
|
for mandateId in mandateIds:
|
||||||
recordFilter={"userId": userId, "featureInstanceId": instanceId}
|
mandateInstances = db.getRecordset(
|
||||||
)
|
FeatureInstance,
|
||||||
return accesses[0] if accesses else None
|
recordFilter={"mandateId": mandateId, "featureCode": featureCode}
|
||||||
|
)
|
||||||
|
for inst in mandateInstances:
|
||||||
|
instanceId = inst.get("id")
|
||||||
|
accesses = db.getRecordset(
|
||||||
|
FeatureAccess,
|
||||||
|
recordFilter={"userId": userId, "featureInstanceId": instanceId}
|
||||||
|
)
|
||||||
|
if accesses:
|
||||||
|
mandate = db.getRecordset(Mandate, recordFilter={"id": mandateId})
|
||||||
|
mandateName = mandate[0].get("label") or mandate[0].get("name") if mandate else mandateId
|
||||||
|
instances.append({
|
||||||
|
"instanceId": instanceId,
|
||||||
|
"mandateId": mandateId,
|
||||||
|
"mandateName": mandateName,
|
||||||
|
"label": inst.get("label", ""),
|
||||||
|
"isActive": True,
|
||||||
|
})
|
||||||
|
return instances
|
||||||
|
|
||||||
|
|
||||||
def _findStoreUserRoleId(
|
@router.get("/mandates", response_model=List[Dict[str, Any]])
|
||||||
rootInterface,
|
@limiter.limit("60/minute")
|
||||||
catalogService,
|
def listUserMandates(
|
||||||
instanceId: str,
|
request: Request,
|
||||||
featureCode: str,
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> str | None:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Resolve the feature's primary *user* role on this instance (e.g. workspace-user).
|
List mandates where the user can activate features (admin mandates).
|
||||||
Uses catalog template labels first, then a safe fallback on instance roles.
|
Returns empty list if user has no admin mandates — the frontend handles
|
||||||
|
this via OnboardingAssistant/OnboardingWizard to create a mandate.
|
||||||
"""
|
"""
|
||||||
instanceRoles = rootInterface.getRolesByFeatureInstance(instanceId)
|
try:
|
||||||
labelToId = {r.roleLabel: str(r.id) for r in instanceRoles if r.roleLabel}
|
rootInterface = getRootInterface()
|
||||||
|
db = rootInterface.db
|
||||||
|
userId = str(context.user.id)
|
||||||
|
adminMandateIds = _getUserAdminMandateIds(db, userId)
|
||||||
|
|
||||||
preferred = f"{featureCode}-user"
|
result = []
|
||||||
if preferred in labelToId:
|
for mid in adminMandateIds:
|
||||||
return labelToId[preferred]
|
records = db.getRecordset(Mandate, recordFilter={"id": mid})
|
||||||
|
if records:
|
||||||
|
m = records[0]
|
||||||
|
result.append({
|
||||||
|
"id": mid,
|
||||||
|
"name": m.get("name", ""),
|
||||||
|
"label": m.get("label") or m.get("name", ""),
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error listing user mandates: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
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:
|
@router.get("/subscription-info", response_model=Dict[str, Any])
|
||||||
low = (role.roleLabel or "").lower()
|
@limiter.limit("60/minute")
|
||||||
if "admin" in low:
|
def getSubscriptionInfo(
|
||||||
continue
|
request: Request,
|
||||||
if "user" in low:
|
mandateId: str = None,
|
||||||
return str(role.id)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
return None
|
) -> Dict[str, Any]:
|
||||||
|
"""Get subscription info for a mandate (plan, limits)."""
|
||||||
|
try:
|
||||||
|
rootInterface = getRootInterface()
|
||||||
|
db = rootInterface.db
|
||||||
|
userId = str(context.user.id)
|
||||||
|
|
||||||
|
if not mandateId:
|
||||||
|
adminMandateIds = _getUserAdminMandateIds(db, userId)
|
||||||
|
if adminMandateIds:
|
||||||
|
mandateId = adminMandateIds[0]
|
||||||
|
|
||||||
|
if not mandateId:
|
||||||
|
return {
|
||||||
|
"plan": None,
|
||||||
|
"maxDataVolumeMB": None,
|
||||||
|
"maxFeatureInstances": None,
|
||||||
|
"budgetAiCHF": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
from modules.datamodels.datamodelSubscription import MandateSubscription, BUILTIN_PLANS
|
||||||
|
subs = db.getRecordset(MandateSubscription, recordFilter={"mandateId": mandateId})
|
||||||
|
if not subs:
|
||||||
|
return {
|
||||||
|
"plan": None,
|
||||||
|
"maxDataVolumeMB": None,
|
||||||
|
"maxFeatureInstances": None,
|
||||||
|
"budgetAiCHF": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
sub = subs[0]
|
||||||
|
plan = BUILTIN_PLANS.get(sub.get("planKey"))
|
||||||
|
currentInstances = db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"plan": sub.get("planKey"),
|
||||||
|
"status": sub.get("status"),
|
||||||
|
"maxDataVolumeMB": plan.maxDataVolumeMB if plan else None,
|
||||||
|
"maxFeatureInstances": plan.maxFeatureInstances if plan else None,
|
||||||
|
"budgetAiCHF": plan.budgetAiCHF if plan else None,
|
||||||
|
"currentFeatureInstances": len(currentInstances),
|
||||||
|
"trialEndsAt": sub.get("trialEndsAt"),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting subscription info: {e}")
|
||||||
|
return {
|
||||||
|
"plan": None,
|
||||||
|
"maxDataVolumeMB": None,
|
||||||
|
"maxFeatureInstances": None,
|
||||||
|
"budgetAiCHF": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/features", response_model=List[StoreFeatureResponse])
|
@router.get("/features", response_model=List[StoreFeatureResponse])
|
||||||
|
|
@ -152,47 +226,33 @@ def listStoreFeatures(
|
||||||
request: Request,
|
request: Request,
|
||||||
context: RequestContext = Depends(getRequestContext)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> List[StoreFeatureResponse]:
|
) -> List[StoreFeatureResponse]:
|
||||||
"""
|
"""List all store features with activation status per mandate."""
|
||||||
List all store features with activation status and permissions.
|
|
||||||
|
|
||||||
Returns the store catalog showing which features are available,
|
|
||||||
which are already activated, and whether the user can activate them.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
rootInterface = getRootInterface()
|
rootInterface = getRootInterface()
|
||||||
db = rootInterface.db
|
db = rootInterface.db
|
||||||
catalogService = getCatalogService()
|
catalogService = getCatalogService()
|
||||||
|
userId = str(context.user.id)
|
||||||
|
|
||||||
rootMandateId = _getRootMandateId(db)
|
userMandates = db.getRecordset(UserMandate, recordFilter={"userId": userId, "enabled": True})
|
||||||
if not rootMandateId:
|
userMandateIds = []
|
||||||
raise HTTPException(
|
for um in userMandates:
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
mid = um.get("mandateId")
|
||||||
detail="Root mandate not found"
|
mRecord = db.getRecordset(Mandate, recordFilter={"id": mid})
|
||||||
)
|
if mRecord and not mRecord[0].get("isSystem"):
|
||||||
|
userMandateIds.append(mid)
|
||||||
|
|
||||||
storeFeatures = _getStoreFeatures(catalogService)
|
storeFeatures = _getStoreFeatures(catalogService)
|
||||||
userId = str(context.user.id)
|
|
||||||
result = []
|
result = []
|
||||||
|
|
||||||
for featureDef in storeFeatures:
|
for featureDef in storeFeatures:
|
||||||
featureCode = featureDef["code"]
|
featureCode = featureDef["code"]
|
||||||
sharedInstance = _findSharedInstance(db, rootMandateId, featureCode)
|
instances = _getUserInstancesForFeature(db, userId, featureCode, userMandateIds)
|
||||||
instanceId = sharedInstance.get("id") if sharedInstance else None
|
|
||||||
|
|
||||||
isActive = False
|
|
||||||
if instanceId:
|
|
||||||
existingAccess = _getUserFeatureAccess(db, userId, instanceId)
|
|
||||||
isActive = existingAccess is not None
|
|
||||||
|
|
||||||
canActivate = _checkStorePermission(context, featureCode) and not isActive
|
|
||||||
|
|
||||||
result.append(StoreFeatureResponse(
|
result.append(StoreFeatureResponse(
|
||||||
featureCode=featureCode,
|
featureCode=featureCode,
|
||||||
label=featureDef.get("label", {}),
|
label=featureDef.get("label", {}),
|
||||||
icon=featureDef.get("icon", "mdi-puzzle"),
|
icon=featureDef.get("icon", "mdi-puzzle"),
|
||||||
isActive=isActive,
|
instances=instances,
|
||||||
canActivate=canActivate,
|
canActivate=True,
|
||||||
instanceId=instanceId,
|
|
||||||
))
|
))
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
@ -201,10 +261,7 @@ def listStoreFeatures(
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error listing store features: {e}")
|
logger.error(f"Error listing store features: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to list store features: {str(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/activate", response_model=Dict[str, Any])
|
@router.post("/activate", response_model=Dict[str, Any])
|
||||||
|
|
@ -215,10 +272,9 @@ def activateStoreFeature(
|
||||||
context: RequestContext = Depends(getRequestContext)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Activate a store feature for the current user.
|
Activate a store feature. Billing-gated: a feature instance is ONLY created
|
||||||
|
if the Stripe subscription quantity update succeeds (proration confirmed).
|
||||||
Creates FeatureAccess + FeatureAccessRole on the shared instance
|
On any billing failure the provisioned instance is rolled back.
|
||||||
in the root mandate. The user gets the feature's user-level role.
|
|
||||||
"""
|
"""
|
||||||
featureCode = data.featureCode
|
featureCode = data.featureCode
|
||||||
userId = str(context.user.id)
|
userId = str(context.user.id)
|
||||||
|
|
@ -226,82 +282,107 @@ def activateStoreFeature(
|
||||||
try:
|
try:
|
||||||
rootInterface = getRootInterface()
|
rootInterface = getRootInterface()
|
||||||
db = rootInterface.db
|
db = rootInterface.db
|
||||||
|
|
||||||
if not _checkStorePermission(context, featureCode):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail=f"No permission to activate feature '{featureCode}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
catalogService = getCatalogService()
|
catalogService = getCatalogService()
|
||||||
|
|
||||||
featureDef = catalogService.getFeatureDefinition(featureCode)
|
featureDef = catalogService.getFeatureDefinition(featureCode)
|
||||||
if not featureDef:
|
if not featureDef:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Feature '{featureCode}' not found")
|
||||||
|
|
||||||
|
mandateId = data.mandateId
|
||||||
|
|
||||||
|
if not _isUserAdminInMandate(db, userId, mandateId):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not admin in target mandate")
|
||||||
|
|
||||||
|
# ── 1. Resolve subscription & plan ──────────────────────────────
|
||||||
|
from modules.datamodels.datamodelSubscription import MandateSubscription, BUILTIN_PLANS, SubscriptionStatusEnum
|
||||||
|
from modules.interfaces.interfaceDbSubscription import _getRootInterface as _getSubRoot
|
||||||
|
|
||||||
|
subInterface = _getSubRoot()
|
||||||
|
operative = subInterface.getOperativeForMandate(mandateId)
|
||||||
|
if not operative:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||||
detail=f"Feature '{featureCode}' not found"
|
detail="Kein aktives Abonnement. Bitte zuerst ein Abo abschliessen.",
|
||||||
)
|
)
|
||||||
|
|
||||||
rootMandateId = _getRootMandateId(db)
|
planKey = operative.get("planKey", "")
|
||||||
if not rootMandateId:
|
plan = BUILTIN_PLANS.get(planKey)
|
||||||
|
isBillable = plan is not None and (plan.pricePerFeatureInstanceCHF or 0) > 0
|
||||||
|
|
||||||
|
if isBillable:
|
||||||
|
if not operative.get("stripeSubscriptionId") or not operative.get("stripeItemIdInstances"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||||
|
detail="Stripe-Abonnement ist nicht vollständig eingerichtet — Aktivierung nicht möglich.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 2. Capacity check ───────────────────────────────────────────
|
||||||
|
if plan and plan.maxFeatureInstances is not None:
|
||||||
|
currentInstances = db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId})
|
||||||
|
if len(currentInstances) >= plan.maxFeatureInstances:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||||
|
detail=f"Feature-Instanz-Limit erreicht ({plan.maxFeatureInstances}). Bitte Plan upgraden.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 3. Provision instance ───────────────────────────────────────
|
||||||
|
featureInterface = getFeatureInterface(db)
|
||||||
|
featureLabel = featureDef.get("label", {}).get("en", featureCode)
|
||||||
|
instance = featureInterface.createFeatureInstance(
|
||||||
|
featureCode=featureCode,
|
||||||
|
mandateId=mandateId,
|
||||||
|
label=featureLabel,
|
||||||
|
enabled=True,
|
||||||
|
copyTemplateRoles=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not instance:
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create feature instance")
|
||||||
|
|
||||||
|
instanceId = instance.get("id") if isinstance(instance, dict) else instance.id
|
||||||
|
|
||||||
|
instanceRoles = db.getRecordset(Role, recordFilter={"featureInstanceId": instanceId})
|
||||||
|
adminRoleId = None
|
||||||
|
for ir in instanceRoles:
|
||||||
|
roleLabel = (ir.get("roleLabel") or "").lower()
|
||||||
|
if roleLabel.endswith("-admin"):
|
||||||
|
adminRoleId = ir.get("id")
|
||||||
|
break
|
||||||
|
|
||||||
|
if not adminRoleId:
|
||||||
|
_rollbackInstance(db, instanceId)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail="Root mandate not found"
|
detail=f"Keine Feature-Admin-Rolle für {featureCode} gefunden — Rollback.",
|
||||||
)
|
)
|
||||||
|
|
||||||
sharedInstance = _findSharedInstance(db, rootMandateId, featureCode)
|
rootInterface.createFeatureAccess(userId, instanceId, roleIds=[adminRoleId])
|
||||||
if not sharedInstance:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"Shared instance for '{featureCode}' not found in root mandate"
|
|
||||||
)
|
|
||||||
|
|
||||||
instanceId = sharedInstance.get("id")
|
# ── 4. Billing gate: Stripe quantity sync (MUST succeed) ────────
|
||||||
|
if isBillable:
|
||||||
|
try:
|
||||||
|
rootInterface._syncSubscriptionQuantity(mandateId, raiseOnError=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Stripe billing for feature activation failed — rolling back instance %s: %s", instanceId, e)
|
||||||
|
_rollbackInstance(db, instanceId, userId=userId)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||||
|
detail=f"Stripe-Abrechnung fehlgeschlagen: {e}. Feature wurde NICHT aktiviert.",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
rootInterface._syncSubscriptionQuantity(mandateId)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Non-critical Stripe sync failed for free feature: %s", e)
|
||||||
|
|
||||||
existingAccess = _getUserFeatureAccess(db, userId, instanceId)
|
# ── 5. Confirmed — notify ──────────────────────────────────────
|
||||||
if existingAccess:
|
_notifyFeatureActivation(mandateId, featureLabel, featureCode, sub=operative, plan=plan)
|
||||||
raise HTTPException(
|
logger.info("User %s activated '%s' in mandate %s (instance=%s, billed=%s)", userId, featureCode, mandateId, instanceId, isBillable)
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail=f"Feature '{featureCode}' is already active"
|
|
||||||
)
|
|
||||||
|
|
||||||
featureAccess = FeatureAccess(
|
|
||||||
userId=userId,
|
|
||||||
featureInstanceId=instanceId,
|
|
||||||
enabled=True
|
|
||||||
)
|
|
||||||
createdAccess = db.recordCreate(FeatureAccess, featureAccess.model_dump())
|
|
||||||
featureAccessId = createdAccess.get("id")
|
|
||||||
|
|
||||||
userRoleId = _findStoreUserRoleId(rootInterface, catalogService, instanceId, featureCode)
|
|
||||||
if not userRoleId:
|
|
||||||
db.recordDelete(FeatureAccess, featureAccessId)
|
|
||||||
logger.error(
|
|
||||||
f"Store activate rollback: no user role on instance {instanceId} for feature '{featureCode}'"
|
|
||||||
)
|
|
||||||
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(
|
|
||||||
f"User {userId} activated store feature '{featureCode}' "
|
|
||||||
f"(instance={instanceId}, role={userRoleId})"
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"featureCode": featureCode,
|
"featureCode": featureCode,
|
||||||
|
"mandateId": mandateId,
|
||||||
"instanceId": instanceId,
|
"instanceId": instanceId,
|
||||||
"featureAccessId": featureAccessId,
|
|
||||||
"roleId": userRoleId,
|
|
||||||
"activated": True,
|
"activated": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -309,71 +390,115 @@ def activateStoreFeature(
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error activating store feature '{featureCode}': {e}")
|
logger.error(f"Error activating store feature '{featureCode}': {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to activate feature: {str(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/deactivate", response_model=Dict[str, Any])
|
@router.post("/deactivate", response_model=Dict[str, Any])
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
def deactivateStoreFeature(
|
def deactivateStoreFeature(
|
||||||
request: Request,
|
request: Request,
|
||||||
data: StoreActivateRequest,
|
data: StoreDeactivateRequest,
|
||||||
context: RequestContext = Depends(getRequestContext)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Deactivate a store feature for the current user.
|
Deactivate a store feature. Removes user's FeatureAccess.
|
||||||
|
Orphan Control: if last user deactivates, FeatureInstance is deleted.
|
||||||
Removes FeatureAccess (CASCADE deletes FeatureAccessRole).
|
|
||||||
User loses access immediately.
|
|
||||||
"""
|
"""
|
||||||
featureCode = data.featureCode
|
|
||||||
userId = str(context.user.id)
|
userId = str(context.user.id)
|
||||||
|
instanceId = data.instanceId
|
||||||
|
mandateId = data.mandateId
|
||||||
|
|
||||||
try:
|
try:
|
||||||
rootInterface = getRootInterface()
|
rootInterface = getRootInterface()
|
||||||
db = rootInterface.db
|
db = rootInterface.db
|
||||||
|
|
||||||
rootMandateId = _getRootMandateId(db)
|
# Verify instance exists in mandate
|
||||||
if not rootMandateId:
|
instances = db.getRecordset(FeatureInstance, recordFilter={"id": instanceId, "mandateId": mandateId})
|
||||||
raise HTTPException(
|
if not instances:
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feature instance not found in mandate")
|
||||||
detail="Root mandate not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
sharedInstance = _findSharedInstance(db, rootMandateId, featureCode)
|
# Find user's FeatureAccess
|
||||||
if not sharedInstance:
|
accesses = db.getRecordset(FeatureAccess, recordFilter={"userId": userId, "featureInstanceId": instanceId})
|
||||||
raise HTTPException(
|
if not accesses:
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No active access found")
|
||||||
detail=f"Shared instance for '{featureCode}' not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
instanceId = sharedInstance.get("id")
|
featureAccessId = accesses[0].get("id")
|
||||||
|
|
||||||
existingAccess = _getUserFeatureAccess(db, userId, instanceId)
|
|
||||||
if not existingAccess:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"Feature '{featureCode}' is not active"
|
|
||||||
)
|
|
||||||
|
|
||||||
featureAccessId = existingAccess.get("id")
|
|
||||||
db.recordDelete(FeatureAccess, featureAccessId)
|
db.recordDelete(FeatureAccess, featureAccessId)
|
||||||
|
|
||||||
logger.info(f"User {userId} deactivated store feature '{featureCode}' (instance={instanceId})")
|
# Orphan Control: check if any FeatureAccess remains
|
||||||
|
remainingAccesses = db.getRecordset(FeatureAccess, recordFilter={"featureInstanceId": instanceId})
|
||||||
|
instanceDeleted = False
|
||||||
|
if not remainingAccesses:
|
||||||
|
db.recordDelete(FeatureInstance, instanceId)
|
||||||
|
instanceDeleted = True
|
||||||
|
logger.info(f"Orphan Control: deleted instance {instanceId} (no remaining accesses)")
|
||||||
|
|
||||||
|
try:
|
||||||
|
rootInterface._syncSubscriptionQuantity(mandateId, raiseOnError=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Stripe quantity sync after deactivation failed for mandate %s: %s", mandateId, e)
|
||||||
|
|
||||||
|
logger.info(f"User {userId} deactivated instance {instanceId} in mandate {mandateId} (deleted={instanceDeleted})")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"featureCode": featureCode,
|
"featureCode": data.featureCode,
|
||||||
|
"mandateId": mandateId,
|
||||||
"instanceId": instanceId,
|
"instanceId": instanceId,
|
||||||
"deactivated": True,
|
"deactivated": True,
|
||||||
|
"instanceDeleted": instanceDeleted,
|
||||||
}
|
}
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error deactivating store feature '{featureCode}': {e}")
|
logger.error(f"Error deactivating store feature: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to deactivate feature: {str(e)}"
|
|
||||||
|
# ============================================================================
|
||||||
|
# Internal helpers
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def _rollbackInstance(db, instanceId: str, userId: str = None) -> None:
|
||||||
|
"""Delete a freshly provisioned FeatureInstance (and its access) on billing failure."""
|
||||||
|
try:
|
||||||
|
if userId:
|
||||||
|
accesses = db.getRecordset(FeatureAccess, recordFilter={"userId": userId, "featureInstanceId": instanceId})
|
||||||
|
for a in accesses:
|
||||||
|
db.recordDelete(FeatureAccess, a.get("id"))
|
||||||
|
db.recordDelete(FeatureInstance, instanceId)
|
||||||
|
logger.info("Rolled back feature instance %s (billing gate)", instanceId)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Rollback of instance %s failed: %s", instanceId, e)
|
||||||
|
|
||||||
|
|
||||||
|
def _notifyFeatureActivation(
|
||||||
|
mandateId: str,
|
||||||
|
featureLabel: str,
|
||||||
|
featureCode: str,
|
||||||
|
sub: dict = None,
|
||||||
|
plan = None,
|
||||||
|
) -> None:
|
||||||
|
"""Send email notification to mandate admins about a newly activated feature."""
|
||||||
|
try:
|
||||||
|
from modules.shared.notifyMandateAdmins import notifyMandateAdmins
|
||||||
|
|
||||||
|
priceLine = ""
|
||||||
|
if plan and plan.pricePerFeatureInstanceCHF:
|
||||||
|
priceLine = f"Kosten: CHF {plan.pricePerFeatureInstanceCHF:.2f} / {plan.billingPeriod.value} (anteilig via Stripe-Proration)."
|
||||||
|
|
||||||
|
bodyParagraphs = [
|
||||||
|
f"Die Feature-Instanz «{featureLabel}» ({featureCode}) wurde soeben für Ihren Mandanten aktiviert.",
|
||||||
|
]
|
||||||
|
if priceLine:
|
||||||
|
bodyParagraphs.append(priceLine)
|
||||||
|
bodyParagraphs.append("Die Stripe-Abrechnung wird automatisch angepasst.")
|
||||||
|
|
||||||
|
notifyMandateAdmins(
|
||||||
|
mandateId=mandateId,
|
||||||
|
subject=f"Feature aktiviert: {featureLabel}",
|
||||||
|
headline="Neue Feature-Instanz aktiviert",
|
||||||
|
bodyParagraphs=bodyParagraphs,
|
||||||
)
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("_notifyFeatureActivation failed for mandate %s: %s", mandateId, e)
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ Endpoints:
|
||||||
- POST /api/subscription/force-cancel — sysadmin immediate cancel (by ID)
|
- POST /api/subscription/force-cancel — sysadmin immediate cancel (by ID)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Request, Query
|
from fastapi import APIRouter, HTTPException, Depends, Request, Query, Path
|
||||||
from fastapi import status
|
from fastapi import status
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Optional
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -183,7 +183,7 @@ def activatePlan(
|
||||||
|
|
||||||
|
|
||||||
@router.post("/cancel", response_model=Dict[str, Any])
|
@router.post("/cancel", response_model=Dict[str, Any])
|
||||||
@limiter.limit("5/minute")
|
@limiter.limit("30/minute")
|
||||||
def cancelSubscription(
|
def cancelSubscription(
|
||||||
request: Request,
|
request: Request,
|
||||||
data: CancelRequest,
|
data: CancelRequest,
|
||||||
|
|
@ -209,7 +209,7 @@ def cancelSubscription(
|
||||||
|
|
||||||
|
|
||||||
@router.post("/reactivate", response_model=Dict[str, Any])
|
@router.post("/reactivate", response_model=Dict[str, Any])
|
||||||
@limiter.limit("5/minute")
|
@limiter.limit("30/minute")
|
||||||
def reactivateSubscription(
|
def reactivateSubscription(
|
||||||
request: Request,
|
request: Request,
|
||||||
data: ReactivateRequest,
|
data: ReactivateRequest,
|
||||||
|
|
@ -235,7 +235,7 @@ def reactivateSubscription(
|
||||||
|
|
||||||
|
|
||||||
@router.post("/force-cancel", response_model=Dict[str, Any])
|
@router.post("/force-cancel", response_model=Dict[str, Any])
|
||||||
@limiter.limit("5/minute")
|
@limiter.limit("30/minute")
|
||||||
def forceCancel(
|
def forceCancel(
|
||||||
request: Request,
|
request: Request,
|
||||||
data: ForceCancelRequest,
|
data: ForceCancelRequest,
|
||||||
|
|
@ -435,3 +435,69 @@ def getFilterValues(
|
||||||
crossFiltered = _applyFiltersAndSort(enriched, crossFilterParams)
|
crossFiltered = _applyFiltersAndSort(enriched, crossFilterParams)
|
||||||
|
|
||||||
return _extractDistinctValues(crossFiltered, column)
|
return _extractDistinctValues(crossFiltered, column)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Data Volume Usage per Mandate
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@router.get("/data-volume/{targetMandateId}")
|
||||||
|
@limiter.limit("60/minute")
|
||||||
|
def _getDataVolumeUsage(
|
||||||
|
request: Request,
|
||||||
|
targetMandateId: str = Path(..., description="Mandate ID to check volume for"),
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
):
|
||||||
|
"""Calculate current data volume usage for a mandate vs. plan limit."""
|
||||||
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
|
from modules.datamodels.datamodelFiles import FileItem
|
||||||
|
from modules.datamodels.datamodelFeatures import FeatureInstance
|
||||||
|
from modules.interfaces.interfaceDbKnowledge import aggregateMandateRagTotalBytes
|
||||||
|
from modules.interfaces.interfaceDbManagement import getInterface as getMgmtInterface
|
||||||
|
from modules.interfaces.interfaceDbSubscription import _getRootInterface as _getSubRootIf
|
||||||
|
|
||||||
|
rootIf = getRootInterface()
|
||||||
|
mandateId = targetMandateId
|
||||||
|
|
||||||
|
instances = rootIf.db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId})
|
||||||
|
instIds = [str(inst.get("id") or "") for inst in instances if inst.get("id")]
|
||||||
|
|
||||||
|
mgmtDb = getMgmtInterface().db
|
||||||
|
totalFileBytes = 0
|
||||||
|
for instId in instIds:
|
||||||
|
files = mgmtDb.getRecordset(FileItem, recordFilter={"featureInstanceId": instId})
|
||||||
|
for f in files:
|
||||||
|
size = f.get("fileSize") if isinstance(f, dict) else getattr(f, "fileSize", 0)
|
||||||
|
totalFileBytes += (size or 0)
|
||||||
|
mandateFiles = mgmtDb.getRecordset(FileItem, recordFilter={"mandateId": mandateId})
|
||||||
|
for f in mandateFiles:
|
||||||
|
size = f.get("fileSize") if isinstance(f, dict) else getattr(f, "fileSize", 0)
|
||||||
|
totalFileBytes += (size or 0)
|
||||||
|
filesMB = round(totalFileBytes / (1024 * 1024), 2)
|
||||||
|
|
||||||
|
ragBytes = aggregateMandateRagTotalBytes(mandateId)
|
||||||
|
ragMB = round(ragBytes / (1024 * 1024), 2)
|
||||||
|
|
||||||
|
maxMB = None
|
||||||
|
subIf = _getSubRootIf()
|
||||||
|
operative = subIf.getOperativeForMandate(mandateId)
|
||||||
|
if operative:
|
||||||
|
plan = subIf.getPlan(operative.get("planKey") or "")
|
||||||
|
if plan and plan.maxDataVolumeMB is not None:
|
||||||
|
maxMB = int(plan.maxDataVolumeMB)
|
||||||
|
|
||||||
|
usedMB = ragMB
|
||||||
|
percentUsed = round((usedMB / maxMB) * 100, 1) if maxMB else None
|
||||||
|
logger.info(
|
||||||
|
"data-volume mandate=%s: files=%.2f MB, rag=%.2f MB, max=%s MB",
|
||||||
|
mandateId, filesMB, ragMB, maxMB,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"mandateId": mandateId,
|
||||||
|
"usedMB": usedMB,
|
||||||
|
"filesMB": filesMB,
|
||||||
|
"ragIndexMB": ragMB,
|
||||||
|
"maxDataVolumeMB": maxMB,
|
||||||
|
"percentUsed": percentUsed,
|
||||||
|
"warning": (percentUsed or 0) >= 80,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -247,12 +247,12 @@ def _buildDynamicBlock(
|
||||||
# Sort views by order
|
# Sort views by order
|
||||||
views.sort(key=lambda v: v["order"])
|
views.sort(key=lambda v: v["order"])
|
||||||
|
|
||||||
# Add instance to feature
|
|
||||||
featuresMap[featureKey]["instances"].append({
|
featuresMap[featureKey]["instances"].append({
|
||||||
"id": str(instance.id),
|
"id": str(instance.id),
|
||||||
"uiLabel": instance.label,
|
"uiLabel": instance.label,
|
||||||
"order": 10,
|
"order": 10,
|
||||||
"views": views
|
"views": views,
|
||||||
|
"isAdmin": permissions.get("isAdmin", False),
|
||||||
})
|
})
|
||||||
|
|
||||||
# Build final structure
|
# Build final structure
|
||||||
|
|
|
||||||
|
|
@ -442,113 +442,52 @@ async def health_check(currentUser: User = Depends(getCurrentUser)):
|
||||||
|
|
||||||
@router.get("/settings")
|
@router.get("/settings")
|
||||||
async def get_voice_settings(currentUser: User = Depends(getCurrentUser)):
|
async def get_voice_settings(currentUser: User = Depends(getCurrentUser)):
|
||||||
"""Get voice settings for the current user."""
|
"""Get voice settings for the current user (reads from UserVoicePreferences)."""
|
||||||
try:
|
from modules.datamodels.datamodelUam import UserVoicePreferences
|
||||||
logger.info(f"Getting voice settings for user: {currentUser.id}")
|
from modules.security.rootAccess import getRootInterface
|
||||||
|
rootInterface = getRootInterface()
|
||||||
# Get voice interface
|
userId = str(currentUser.id)
|
||||||
voiceInterface = _getVoiceInterface(currentUser)
|
|
||||||
|
prefs = rootInterface.db.getRecordset(
|
||||||
# Get or create voice settings for the user
|
UserVoicePreferences, recordFilter={"userId": userId}
|
||||||
voice_settings = voiceInterface.getOrCreateVoiceSettings(currentUser.id)
|
)
|
||||||
|
if prefs:
|
||||||
if voice_settings:
|
data = prefs[0] if isinstance(prefs[0], dict) else prefs[0].model_dump()
|
||||||
# Return user settings
|
return {"success": True, "data": {"user_settings": data}}
|
||||||
return {
|
return {"success": True, "data": {"user_settings": UserVoicePreferences(userId=userId).model_dump()}}
|
||||||
"success": True,
|
|
||||||
"data": {
|
|
||||||
"user_settings": voice_settings.model_dump(),
|
|
||||||
"default_settings": {
|
|
||||||
"sttLanguage": "de-DE",
|
|
||||||
"ttsLanguage": "de-DE",
|
|
||||||
"ttsVoice": "de-DE-Wavenet-A",
|
|
||||||
"translationEnabled": True,
|
|
||||||
"targetLanguage": "en-US"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
# Fallback to default settings if database fails
|
|
||||||
logger.warning("Failed to get voice settings from database, using defaults")
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"data": {
|
|
||||||
"user_settings": None,
|
|
||||||
"default_settings": {
|
|
||||||
"sttLanguage": "de-DE",
|
|
||||||
"ttsLanguage": "de-DE",
|
|
||||||
"ttsVoice": "de-DE-Wavenet-A",
|
|
||||||
"translationEnabled": True,
|
|
||||||
"targetLanguage": "en-US"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting voice settings: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=f"Failed to get voice settings: {str(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
@router.post("/settings")
|
@router.post("/settings")
|
||||||
async def save_voice_settings(
|
async def save_voice_settings(
|
||||||
settings: Dict[str, Any] = Body(...),
|
settings: Dict[str, Any] = Body(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
currentUser: User = Depends(getCurrentUser)
|
||||||
):
|
):
|
||||||
"""Save voice settings for the current user."""
|
"""Save voice settings for the current user (writes to UserVoicePreferences)."""
|
||||||
try:
|
from modules.datamodels.datamodelUam import UserVoicePreferences, _normalizeTtsVoiceMap
|
||||||
logger.info(f"Saving voice settings for user: {currentUser.id}")
|
from modules.security.rootAccess import getRootInterface
|
||||||
logger.info(f"Settings: {settings}")
|
rootInterface = getRootInterface()
|
||||||
|
userId = str(currentUser.id)
|
||||||
# Validate required settings
|
|
||||||
requiredFields = ["sttLanguage", "ttsLanguage", "ttsVoice"]
|
allowedFields = {
|
||||||
for field in requiredFields:
|
"sttLanguage", "ttsLanguage", "ttsVoice", "ttsVoiceMap",
|
||||||
if field not in settings:
|
"translationSourceLanguage", "translationTargetLanguage",
|
||||||
raise HTTPException(
|
}
|
||||||
status_code=400,
|
updateData = {k: v for k, v in settings.items() if k in allowedFields}
|
||||||
detail=f"Missing required field: {field}"
|
if "ttsVoiceMap" in updateData:
|
||||||
)
|
updateData["ttsVoiceMap"] = _normalizeTtsVoiceMap(updateData["ttsVoiceMap"])
|
||||||
|
|
||||||
# Set default values for optional fields if not provided
|
existing = rootInterface.db.getRecordset(
|
||||||
if "translationEnabled" not in settings:
|
UserVoicePreferences, recordFilter={"userId": userId}
|
||||||
settings["translationEnabled"] = True
|
)
|
||||||
if "targetLanguage" not in settings:
|
if existing:
|
||||||
settings["targetLanguage"] = "en-US"
|
existingRecord = existing[0]
|
||||||
|
existingId = existingRecord.get("id") if isinstance(existingRecord, dict) else existingRecord.id
|
||||||
# Get voice interface
|
rootInterface.db.recordModify(UserVoicePreferences, existingId, updateData)
|
||||||
voiceInterface = _getVoiceInterface(currentUser)
|
else:
|
||||||
|
newPrefs = UserVoicePreferences(userId=userId, **updateData)
|
||||||
# Check if settings already exist for this user
|
rootInterface.db.recordCreate(UserVoicePreferences, newPrefs.model_dump())
|
||||||
existing_settings = voiceInterface.getVoiceSettings(currentUser.id)
|
|
||||||
|
return {"success": True, "message": "Voice settings saved successfully", "data": updateData}
|
||||||
if existing_settings:
|
|
||||||
# Update existing settings
|
|
||||||
logger.info(f"Updating existing voice settings for user {currentUser.id}")
|
|
||||||
updated_settings = voiceInterface.updateVoiceSettings(currentUser.id, settings)
|
|
||||||
logger.info(f"Voice settings updated for user {currentUser.id}: {updated_settings}")
|
|
||||||
else:
|
|
||||||
# Create new settings
|
|
||||||
logger.info(f"Creating new voice settings for user {currentUser.id}")
|
|
||||||
# Add userId to settings
|
|
||||||
settings["userId"] = currentUser.id
|
|
||||||
created_settings = voiceInterface.createVoiceSettings(settings)
|
|
||||||
logger.info(f"Voice settings created for user {currentUser.id}: {created_settings}")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"message": "Voice settings saved successfully",
|
|
||||||
"data": settings
|
|
||||||
}
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error saving voice settings: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=f"Failed to save voice settings: {str(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# STT Streaming WebSocket — generic, used by all features
|
# STT Streaming WebSocket — generic, used by all features
|
||||||
|
|
|
||||||
329
modules/routes/routeVoiceUser.py
Normal file
329
modules/routes/routeVoiceUser.py
Normal file
|
|
@ -0,0 +1,329 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""
|
||||||
|
User-scoped voice settings and TTS/STT catalog endpoints.
|
||||||
|
|
||||||
|
Uses modules.interfaces.interfaceVoiceObjects (voice core) and persists preferences
|
||||||
|
via UserVoicePreferences — same domain as routeVoiceGoogle (Google connector ops).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, status
|
||||||
|
|
||||||
|
from modules.auth import getCurrentUser, limiter
|
||||||
|
from modules.datamodels.datamodelUam import User, UserVoicePreferences, _normalizeTtsVoiceMap
|
||||||
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
|
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/api/voice",
|
||||||
|
tags=["Voice User"],
|
||||||
|
responses={
|
||||||
|
404: {"description": "Not found"},
|
||||||
|
400: {"description": "Bad request"},
|
||||||
|
401: {"description": "Unauthorized"},
|
||||||
|
403: {"description": "Forbidden"},
|
||||||
|
500: {"description": "Internal server error"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/preferences")
|
||||||
|
@limiter.limit("60/minute")
|
||||||
|
def getVoicePreferences(
|
||||||
|
request: Request,
|
||||||
|
currentUser: User = Depends(getCurrentUser),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Get user's voice/language preferences (optionally scoped to mandate via header)."""
|
||||||
|
rootInterface = getRootInterface()
|
||||||
|
mandateId = request.headers.get("X-Mandate-Id") or None
|
||||||
|
userId = str(currentUser.id)
|
||||||
|
|
||||||
|
prefs = rootInterface.db.getRecordset(
|
||||||
|
UserVoicePreferences,
|
||||||
|
recordFilter={"userId": userId, "mandateId": mandateId},
|
||||||
|
)
|
||||||
|
if prefs:
|
||||||
|
return prefs[0] if isinstance(prefs[0], dict) else prefs[0].model_dump()
|
||||||
|
return UserVoicePreferences(userId=userId, mandateId=mandateId).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/preferences")
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
def updateVoicePreferences(
|
||||||
|
request: Request,
|
||||||
|
preferences: Dict[str, Any] = Body(...),
|
||||||
|
currentUser: User = Depends(getCurrentUser),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Update user's voice/language preferences (upsert)."""
|
||||||
|
rootInterface = getRootInterface()
|
||||||
|
mandateId = request.headers.get("X-Mandate-Id") or None
|
||||||
|
userId = str(currentUser.id)
|
||||||
|
|
||||||
|
existing = rootInterface.db.getRecordset(
|
||||||
|
UserVoicePreferences,
|
||||||
|
recordFilter={"userId": userId, "mandateId": mandateId},
|
||||||
|
)
|
||||||
|
|
||||||
|
allowedFields = {
|
||||||
|
"sttLanguage",
|
||||||
|
"ttsLanguage",
|
||||||
|
"ttsVoice",
|
||||||
|
"ttsVoiceMap",
|
||||||
|
"translationSourceLanguage",
|
||||||
|
"translationTargetLanguage",
|
||||||
|
}
|
||||||
|
updateData = {k: v for k, v in preferences.items() if k in allowedFields}
|
||||||
|
if "ttsVoiceMap" in updateData:
|
||||||
|
updateData["ttsVoiceMap"] = _normalizeTtsVoiceMap(updateData["ttsVoiceMap"])
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
existingRecord = existing[0]
|
||||||
|
existingId = existingRecord.get("id") if isinstance(existingRecord, dict) else existingRecord.id
|
||||||
|
rootInterface.db.recordModify(UserVoicePreferences, existingId, updateData)
|
||||||
|
updated = rootInterface.db.getRecordset(UserVoicePreferences, recordFilter={"id": existingId})
|
||||||
|
return updated[0] if updated else {"message": "Updated", **updateData}
|
||||||
|
newPrefs = UserVoicePreferences(userId=userId, mandateId=mandateId, **updateData)
|
||||||
|
created = rootInterface.db.recordCreate(UserVoicePreferences, newPrefs.model_dump())
|
||||||
|
return created if isinstance(created, dict) else created.model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/languages")
|
||||||
|
@limiter.limit("120/minute")
|
||||||
|
async def getVoiceLanguages(
|
||||||
|
request: Request,
|
||||||
|
currentUser: User = Depends(getCurrentUser),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Return available TTS languages (user-level, no instance context needed)."""
|
||||||
|
voiceInterface = getVoiceInterface(currentUser)
|
||||||
|
languagesResult = await voiceInterface.getAvailableLanguages()
|
||||||
|
languageList = languagesResult.get("languages", []) if isinstance(languagesResult, dict) else languagesResult
|
||||||
|
return {"languages": languageList}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/voices")
|
||||||
|
@limiter.limit("120/minute")
|
||||||
|
async def getVoiceVoices(
|
||||||
|
request: Request,
|
||||||
|
language: str = Query("de-DE"),
|
||||||
|
currentUser: User = Depends(getCurrentUser),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Return available TTS voices for a given language."""
|
||||||
|
voiceInterface = getVoiceInterface(currentUser)
|
||||||
|
voicesResult = await voiceInterface.getAvailableVoices(language)
|
||||||
|
voiceList = voicesResult.get("voices", []) if isinstance(voicesResult, dict) else voicesResult
|
||||||
|
return {"voices": voiceList}
|
||||||
|
|
||||||
|
|
||||||
|
# Same minimum as modules.serviceCenter.services.serviceAi.mainServiceAi._checkBillingBeforeAiCall
|
||||||
|
_MIN_AI_BILLING_ESTIMATE_CHF = 0.01
|
||||||
|
|
||||||
|
|
||||||
|
def _userMandateIds(rootInterface, currentUser: User):
|
||||||
|
memberships = rootInterface.getUserMandates(str(currentUser.id))
|
||||||
|
out = []
|
||||||
|
for um in memberships:
|
||||||
|
mid = getattr(um, "mandateId", None) or (um.get("mandateId") if isinstance(um, dict) else None)
|
||||||
|
if mid:
|
||||||
|
out.append(str(mid))
|
||||||
|
return list(dict.fromkeys(out))
|
||||||
|
|
||||||
|
|
||||||
|
def _mandatePassesAiPoolBilling(currentUser: User, mandateId: str, userId: str) -> bool:
|
||||||
|
"""True if mandate pool passes the same billing gate as AI calls (subscription + pool >= estimate)."""
|
||||||
|
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
|
||||||
|
|
||||||
|
bi = getBillingInterface(currentUser, mandateId)
|
||||||
|
res = bi.checkBalance(mandateId, userId, _MIN_AI_BILLING_ESTIMATE_CHF)
|
||||||
|
return bool(res.allowed)
|
||||||
|
|
||||||
|
|
||||||
|
def _mandatePoolBalanceChf(currentUser: User, mandateId: str) -> float:
|
||||||
|
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
|
||||||
|
|
||||||
|
bi = getBillingInterface(currentUser, mandateId)
|
||||||
|
acc = bi.getMandateAccount(mandateId)
|
||||||
|
if not acc:
|
||||||
|
return 0.0
|
||||||
|
return float(acc.get("balance", 0.0) or 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolveMandateIdForVoiceTestAi(request: Request, currentUser: User) -> str:
|
||||||
|
"""
|
||||||
|
AI sample billing uses mandate pool (PREPAY), not per-user wallet.
|
||||||
|
Prefer X-Mandate-Id when the user is a member and that mandate's pool can pay;
|
||||||
|
otherwise pick the member mandate with the highest pool balance that passes the AI billing check.
|
||||||
|
"""
|
||||||
|
rootInterface = getRootInterface()
|
||||||
|
userId = str(currentUser.id)
|
||||||
|
memberIds = _userMandateIds(rootInterface, currentUser)
|
||||||
|
if not memberIds:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=(
|
||||||
|
"Voice test needs at least one mandate membership for AI billing. "
|
||||||
|
"Join a mandate or open the app from a mandate context."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
headerRaw = (request.headers.get("X-Mandate-Id") or request.headers.get("x-mandate-id") or "").strip()
|
||||||
|
if headerRaw:
|
||||||
|
if headerRaw not in memberIds:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="X-Mandate-Id is not a mandate you belong to.",
|
||||||
|
)
|
||||||
|
if _mandatePassesAiPoolBilling(currentUser, headerRaw, userId):
|
||||||
|
logger.info(
|
||||||
|
"Voice test AI billing: using header mandate %s (pool ok for estimate %.4f CHF)",
|
||||||
|
headerRaw,
|
||||||
|
_MIN_AI_BILLING_ESTIMATE_CHF,
|
||||||
|
)
|
||||||
|
return headerRaw
|
||||||
|
logger.warning(
|
||||||
|
"Voice test AI billing: header mandate %s has insufficient mandate pool or subscription; "
|
||||||
|
"trying other memberships",
|
||||||
|
headerRaw,
|
||||||
|
)
|
||||||
|
|
||||||
|
bestMid = None
|
||||||
|
bestBal = -1.0
|
||||||
|
for mid in memberIds:
|
||||||
|
if not _mandatePassesAiPoolBilling(currentUser, mid, userId):
|
||||||
|
continue
|
||||||
|
bal = _mandatePoolBalanceChf(currentUser, mid)
|
||||||
|
if bal > bestBal:
|
||||||
|
bestBal = bal
|
||||||
|
bestMid = mid
|
||||||
|
|
||||||
|
if bestMid:
|
||||||
|
logger.info(
|
||||||
|
"Voice test AI billing: selected mandate %s (mandate pool %.2f CHF, estimate %.4f CHF)",
|
||||||
|
bestMid,
|
||||||
|
bestBal,
|
||||||
|
_MIN_AI_BILLING_ESTIMATE_CHF,
|
||||||
|
)
|
||||||
|
return bestMid
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||||
|
detail=(
|
||||||
|
"No mandate you belong to has sufficient shared pool balance for AI (or subscription inactive). "
|
||||||
|
"Top up the mandate pool or use a mandate with budget."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitizeAiTtsSample(raw: str) -> str:
|
||||||
|
s = (raw or "").strip()
|
||||||
|
if s.startswith("```"):
|
||||||
|
nl = s.find("\n")
|
||||||
|
if nl != -1:
|
||||||
|
s = s[nl + 1 :]
|
||||||
|
if s.rstrip().endswith("```"):
|
||||||
|
s = s.rstrip()[:-3].strip()
|
||||||
|
if len(s) >= 2 and ((s[0] == s[-1] == '"') or (s[0] == s[-1] == "'")):
|
||||||
|
s = s[1:-1].strip()
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
async def _generateTtsSampleTextForLocale(
|
||||||
|
request: Request,
|
||||||
|
currentUser: User,
|
||||||
|
localeTag: str,
|
||||||
|
) -> str:
|
||||||
|
from modules.serviceCenter import getService
|
||||||
|
from modules.serviceCenter.context import ServiceCenterContext
|
||||||
|
from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum
|
||||||
|
from modules.serviceCenter.services.serviceBilling.mainServiceBilling import (
|
||||||
|
BillingContextError,
|
||||||
|
InsufficientBalanceException,
|
||||||
|
ProviderNotAllowedException,
|
||||||
|
)
|
||||||
|
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionInactiveException
|
||||||
|
|
||||||
|
mandateId = _resolveMandateIdForVoiceTestAi(request, currentUser)
|
||||||
|
ctx = ServiceCenterContext(user=currentUser, mandate_id=mandateId, feature_instance_id=None)
|
||||||
|
aiService = getService("ai", ctx)
|
||||||
|
|
||||||
|
systemPrompt = (
|
||||||
|
"You write short text-to-speech demo lines for end users.\n"
|
||||||
|
"Task: Output exactly one or two natural sentences a user would enjoy hearing when testing a voice.\n"
|
||||||
|
"The entire output MUST be written ONLY in the natural spoken language that matches the given "
|
||||||
|
"BCP-47 locale tag. Do not use any other language.\n"
|
||||||
|
"Do not mention locales, tags, tests, artificial intelligence, or these instructions.\n"
|
||||||
|
"No quotation marks around the text. No markdown. Plain text only."
|
||||||
|
)
|
||||||
|
userPrompt = f"BCP-47 locale tag: `{localeTag}`.\nWrite the sample now."
|
||||||
|
|
||||||
|
aiRequest = AiCallRequest(
|
||||||
|
prompt=userPrompt,
|
||||||
|
context=systemPrompt,
|
||||||
|
requireNeutralization=False,
|
||||||
|
options=AiCallOptions(
|
||||||
|
operationType=OperationTypeEnum.DATA_GENERATE,
|
||||||
|
priority=PriorityEnum.SPEED,
|
||||||
|
processingMode=ProcessingModeEnum.BASIC,
|
||||||
|
compressPrompt=False,
|
||||||
|
compressContext=False,
|
||||||
|
temperature=0.75,
|
||||||
|
maxParts=1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response = await aiService.callAi(aiRequest)
|
||||||
|
except SubscriptionInactiveException as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=e.message) from e
|
||||||
|
except InsufficientBalanceException as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_402_PAYMENT_REQUIRED, detail=str(e)) from e
|
||||||
|
except ProviderNotAllowedException as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=getattr(e, "message", None) or str(e),
|
||||||
|
) from e
|
||||||
|
except BillingContextError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||||
|
|
||||||
|
content = _sanitizeAiTtsSample(getattr(response, "content", None) or "")
|
||||||
|
if getattr(response, "errorCount", 0) or not content:
|
||||||
|
logger.warning("Voice test AI sample empty or errorCount=%s", getattr(response, "errorCount", None))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail="Could not generate voice test sample text.",
|
||||||
|
)
|
||||||
|
if len(content) > 500:
|
||||||
|
content = content[:500].rstrip()
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test")
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
async def testVoice(
|
||||||
|
request: Request,
|
||||||
|
body: Dict[str, Any] = Body(...),
|
||||||
|
currentUser: User = Depends(getCurrentUser),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Test a specific voice. Sample text is AI-generated in the voice locale unless `text` is supplied."""
|
||||||
|
textRaw = body.get("text")
|
||||||
|
language = body.get("language", "de-DE")
|
||||||
|
voiceId = body.get("voiceId")
|
||||||
|
|
||||||
|
text = (textRaw or "").strip() if isinstance(textRaw, str) else ""
|
||||||
|
if not text:
|
||||||
|
text = await _generateTtsSampleTextForLocale(request, currentUser, language)
|
||||||
|
|
||||||
|
voiceInterface = getVoiceInterface(currentUser)
|
||||||
|
result = await voiceInterface.textToSpeech(text=text, languageCode=language, voiceName=voiceId)
|
||||||
|
if result and isinstance(result, dict):
|
||||||
|
audioContent = result.get("audioContent")
|
||||||
|
if audioContent:
|
||||||
|
audioB64 = base64.b64encode(
|
||||||
|
audioContent if isinstance(audioContent, bytes) else audioContent.encode()
|
||||||
|
).decode()
|
||||||
|
return {"success": True, "audio": audioB64, "format": "mp3", "text": text}
|
||||||
|
return {"success": False, "error": "TTS returned no audio"}
|
||||||
|
|
@ -261,7 +261,7 @@ class RbacClass:
|
||||||
# No mandate context: load roles from ALL user's mandates.
|
# No mandate context: load roles from ALL user's mandates.
|
||||||
# Required for user-owned namespaces (files, chat, automation) that
|
# Required for user-owned namespaces (files, chat, automation) that
|
||||||
# are accessed without mandate context (e.g., /api/files/ endpoints).
|
# are accessed without mandate context (e.g., /api/files/ endpoints).
|
||||||
# Data isolation is still enforced by _createdBy WHERE clause.
|
# Data isolation is still enforced by sysCreatedBy WHERE clause.
|
||||||
allUserMandates = self.dbApp.getRecordset(
|
allUserMandates = self.dbApp.getRecordset(
|
||||||
UserMandate,
|
UserMandate,
|
||||||
recordFilter={"userId": user.id, "enabled": True}
|
recordFilter={"userId": user.id, "enabled": True}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,9 @@ class ServiceCenterContext:
|
||||||
feature_instance_id: Optional[str] = None
|
feature_instance_id: Optional[str] = None
|
||||||
workflow_id: Optional[str] = None
|
workflow_id: Optional[str] = None
|
||||||
workflow: Any = None
|
workflow: Any = None
|
||||||
|
requireNeutralization: Optional[bool] = None
|
||||||
|
# When workflow is absent (e.g. workspace agent), billing/UI still need feature code for transactions.
|
||||||
|
feature_code: Optional[str] = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def mandateId(self) -> Optional[str]:
|
def mandateId(self) -> Optional[str]:
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,28 @@ _MAX_TOOL_RESULT_CHARS = 50_000
|
||||||
_BINARY_SIGNATURES = (b"%PDF", b"\x89PNG", b"\xff\xd8\xff", b"GIF8", b"PK\x03\x04", b"Rar!", b"\x1f\x8b")
|
_BINARY_SIGNATURES = (b"%PDF", b"\x89PNG", b"\xff\xd8\xff", b"GIF8", b"PK\x03\x04", b"Rar!", b"\x1f\x8b")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolveFileScope(fileId: str, context: dict) -> tuple:
|
||||||
|
"""Resolve featureInstanceId and mandateId for a file from context or management DB.
|
||||||
|
|
||||||
|
Returns (featureInstanceId, mandateId) — never None, always strings.
|
||||||
|
"""
|
||||||
|
fiId = context.get("featureInstanceId", "") or ""
|
||||||
|
mId = context.get("mandateId", "") or ""
|
||||||
|
if fiId and mId:
|
||||||
|
return fiId, mId
|
||||||
|
try:
|
||||||
|
from modules.datamodels.datamodelFiles import FileItem
|
||||||
|
from modules.interfaces.interfaceDbManagement import ComponentObjects
|
||||||
|
fm = ComponentObjects().db._loadRecord(FileItem, fileId)
|
||||||
|
if fm:
|
||||||
|
_get = (lambda k: fm.get(k, "")) if isinstance(fm, dict) else (lambda k: getattr(fm, k, ""))
|
||||||
|
fiId = fiId or str(_get("featureInstanceId") or "")
|
||||||
|
mId = mId or str(_get("mandateId") or "")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return fiId, mId
|
||||||
|
|
||||||
|
|
||||||
def _looksLikeBinary(data: bytes, sampleSize: int = 1024) -> bool:
|
def _looksLikeBinary(data: bytes, sampleSize: int = 1024) -> bool:
|
||||||
"""Detect binary content by checking for magic bytes and non-printable char ratio."""
|
"""Detect binary content by checking for magic bytes and non-printable char ratio."""
|
||||||
if any(data[:8].startswith(sig) for sig in _BINARY_SIGNATURES):
|
if any(data[:8].startswith(sig) for sig in _BINARY_SIGNATURES):
|
||||||
|
|
@ -322,14 +344,20 @@ class AgentService:
|
||||||
|
|
||||||
def _createAiCallFn(self) -> Callable[[AiCallRequest], AiCallResponse]:
|
def _createAiCallFn(self) -> Callable[[AiCallRequest], AiCallResponse]:
|
||||||
"""Create the AI call function that wraps serviceAi with billing."""
|
"""Create the AI call function that wraps serviceAi with billing."""
|
||||||
|
ctxNeutralization = getattr(self._context, "requireNeutralization", None)
|
||||||
async def _aiCallFn(request: AiCallRequest) -> AiCallResponse:
|
async def _aiCallFn(request: AiCallRequest) -> AiCallResponse:
|
||||||
|
if ctxNeutralization is not None and request.requireNeutralization is None:
|
||||||
|
request.requireNeutralization = ctxNeutralization
|
||||||
aiService = self.services.ai
|
aiService = self.services.ai
|
||||||
return await aiService.callAi(request)
|
return await aiService.callAi(request)
|
||||||
return _aiCallFn
|
return _aiCallFn
|
||||||
|
|
||||||
def _createAiCallStreamFn(self):
|
def _createAiCallStreamFn(self):
|
||||||
"""Create the streaming AI call function. Yields str deltas, then AiCallResponse."""
|
"""Create the streaming AI call function. Yields str deltas, then AiCallResponse."""
|
||||||
|
ctxNeutralization = getattr(self._context, "requireNeutralization", None)
|
||||||
async def _aiCallStreamFn(request: AiCallRequest):
|
async def _aiCallStreamFn(request: AiCallRequest):
|
||||||
|
if ctxNeutralization is not None and request.requireNeutralization is None:
|
||||||
|
request.requireNeutralization = ctxNeutralization
|
||||||
aiService = self.services.ai
|
aiService = self.services.ai
|
||||||
async for chunk in aiService.callAiStream(request):
|
async for chunk in aiService.callAiStream(request):
|
||||||
yield chunk
|
yield chunk
|
||||||
|
|
@ -363,6 +391,7 @@ class AgentService:
|
||||||
featureInstanceId=featureInstanceId,
|
featureInstanceId=featureInstanceId,
|
||||||
mandateId=mandateId,
|
mandateId=mandateId,
|
||||||
workflowHintItems=workflowHintItems,
|
workflowHintItems=workflowHintItems,
|
||||||
|
isSysAdmin=getattr(self.services.user, "isSysAdmin", False),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"RAG context not available: {e}")
|
logger.debug(f"RAG context not available: {e}")
|
||||||
|
|
@ -440,13 +469,13 @@ def _buildWorkflowHintItems(
|
||||||
|
|
||||||
import time as _time
|
import time as _time
|
||||||
now = _time.time()
|
now = _time.time()
|
||||||
others.sort(key=lambda w: w.get("_createdAt") or w.get("startedAt") or 0, reverse=True)
|
others.sort(key=lambda w: w.get("sysCreatedAt") or w.get("startedAt") or 0, reverse=True)
|
||||||
others = others[:10]
|
others = others[:10]
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
for wf in others:
|
for wf in others:
|
||||||
name = wf.get("name") or "(unnamed)"
|
name = wf.get("name") or "(unnamed)"
|
||||||
createdAt = wf.get("_createdAt") or wf.get("startedAt") or 0
|
createdAt = wf.get("sysCreatedAt") or wf.get("startedAt") or 0
|
||||||
ageSec = now - createdAt if createdAt else 0
|
ageSec = now - createdAt if createdAt else 0
|
||||||
if ageSec < 3600:
|
if ageSec < 3600:
|
||||||
ageStr = f"{int(ageSec / 60)}m ago"
|
ageStr = f"{int(ageSec / 60)}m ago"
|
||||||
|
|
@ -595,16 +624,29 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
||||||
if knowledgeService:
|
if knowledgeService:
|
||||||
try:
|
try:
|
||||||
userId = context.get("userId", "")
|
userId = context.get("userId", "")
|
||||||
|
_fiId, _mId = _resolveFileScope(fileId, context)
|
||||||
await knowledgeService.indexFile(
|
await knowledgeService.indexFile(
|
||||||
fileId=fileId, fileName=fileName, mimeType=mimeType,
|
fileId=fileId, fileName=fileName, mimeType=mimeType,
|
||||||
userId=userId, contentObjects=contentObjects,
|
userId=userId, contentObjects=contentObjects,
|
||||||
|
featureInstanceId=_fiId,
|
||||||
|
mandateId=_mId,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
textParts = [o["data"] for o in contentObjects if o["contentType"] != "image"]
|
joined = ""
|
||||||
if textParts:
|
if knowledgeService:
|
||||||
joined = "\n\n".join(textParts)
|
_chunks = knowledgeService._knowledgeDb.getContentChunks(fileId)
|
||||||
|
_textChunks = [
|
||||||
|
c for c in (_chunks or [])
|
||||||
|
if c.get("contentType") != "image" and c.get("data")
|
||||||
|
]
|
||||||
|
if _textChunks:
|
||||||
|
joined = "\n\n".join(c["data"] for c in _textChunks)
|
||||||
|
if not joined:
|
||||||
|
textParts = [o["data"] for o in contentObjects if o["contentType"] != "image"]
|
||||||
|
joined = "\n\n".join(textParts) if textParts else ""
|
||||||
|
if joined:
|
||||||
chunked = _applyOffsetLimit(joined, offset, limit)
|
chunked = _applyOffsetLimit(joined, offset, limit)
|
||||||
if chunked is not None:
|
if chunked is not None:
|
||||||
return ToolResult(toolCallId="", toolName="readFile", success=True, data=chunked)
|
return ToolResult(toolCallId="", toolName="readFile", success=True, data=chunked)
|
||||||
|
|
@ -635,6 +677,36 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
||||||
try:
|
try:
|
||||||
text = rawBytes.decode(encoding)
|
text = rawBytes.decode(encoding)
|
||||||
if text.strip():
|
if text.strip():
|
||||||
|
_fileNeedNeutralize = False
|
||||||
|
try:
|
||||||
|
from modules.datamodels.datamodelFiles import FileItem as _FI
|
||||||
|
from modules.interfaces.interfaceDbManagement import ComponentObjects as _CO
|
||||||
|
_fRec = _CO().db._loadRecord(_FI, fileId)
|
||||||
|
if _fRec:
|
||||||
|
_fG = (lambda k, d=None: _fRec.get(k, d)) if isinstance(_fRec, dict) else (lambda k, d=None: getattr(_fRec, k, d))
|
||||||
|
_fileNeedNeutralize = bool(_fG("neutralize", False))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if _fileNeedNeutralize:
|
||||||
|
try:
|
||||||
|
_nSvc = services.getService("neutralization") if hasattr(services, "getService") else None
|
||||||
|
if _nSvc and hasattr(_nSvc, 'processTextAsync'):
|
||||||
|
_nResult = await _nSvc.processTextAsync(text, fileId)
|
||||||
|
if _nResult and _nResult.get("neutralized_text"):
|
||||||
|
text = _nResult["neutralized_text"]
|
||||||
|
logger.debug(f"readFile: neutralized text for file {fileId}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"readFile: neutralization failed for file {fileId}, blocking text (fail-safe)")
|
||||||
|
return ToolResult(toolCallId="", toolName="readFile", success=True,
|
||||||
|
data="[File requires neutralization but neutralization failed. Content blocked for data protection.]")
|
||||||
|
else:
|
||||||
|
logger.warning(f"readFile: neutralization required but service unavailable for file {fileId}")
|
||||||
|
return ToolResult(toolCallId="", toolName="readFile", success=True,
|
||||||
|
data="[File requires neutralization but service unavailable. Content blocked for data protection.]")
|
||||||
|
except Exception as _nErr:
|
||||||
|
logger.error(f"readFile: neutralization error for file {fileId}: {_nErr}")
|
||||||
|
return ToolResult(toolCallId="", toolName="readFile", success=True,
|
||||||
|
data="[File requires neutralization but an error occurred. Content blocked for data protection.]")
|
||||||
chunked = _applyOffsetLimit(text, offset, limit)
|
chunked = _applyOffsetLimit(text, offset, limit)
|
||||||
if chunked is not None:
|
if chunked is not None:
|
||||||
return ToolResult(toolCallId="", toolName="readFile", success=True, data=chunked)
|
return ToolResult(toolCallId="", toolName="readFile", success=True, data=chunked)
|
||||||
|
|
@ -1556,7 +1628,7 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _resolveDataSource(dsId: str):
|
async def _resolveDataSource(dsId: str):
|
||||||
"""Resolve a DataSource record and return (connectionId, service, path) or raise."""
|
"""Resolve a DataSource record and return (connectionId, service, path, neutralize) or raise."""
|
||||||
chatService = services.chat
|
chatService = services.chat
|
||||||
ds = chatService.getDataSource(dsId) if hasattr(chatService, "getDataSource") else None
|
ds = chatService.getDataSource(dsId) if hasattr(chatService, "getDataSource") else None
|
||||||
if not ds:
|
if not ds:
|
||||||
|
|
@ -1565,11 +1637,12 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
||||||
sourceType = ds.get("sourceType", "")
|
sourceType = ds.get("sourceType", "")
|
||||||
path = ds.get("path", "/")
|
path = ds.get("path", "/")
|
||||||
label = ds.get("label", "")
|
label = ds.get("label", "")
|
||||||
|
neutralize = bool(ds.get("neutralize", False))
|
||||||
service = _SOURCE_TYPE_TO_SERVICE.get(sourceType, sourceType)
|
service = _SOURCE_TYPE_TO_SERVICE.get(sourceType, sourceType)
|
||||||
if not connectionId:
|
if not connectionId:
|
||||||
raise ValueError(f"DataSource '{dsId}' has no connectionId")
|
raise ValueError(f"DataSource '{dsId}' has no connectionId")
|
||||||
logger.info(f"Resolved DataSource '{dsId}' ({label}): sourceType={sourceType}, service={service}, connectionId={connectionId}, path={path[:80]}")
|
logger.info(f"Resolved DataSource '{dsId}' ({label}): sourceType={sourceType}, service={service}, connectionId={connectionId}, path={path[:80]}, neutralize={neutralize}")
|
||||||
return connectionId, service, path
|
return connectionId, service, path, neutralize
|
||||||
|
|
||||||
_MAIL_SERVICES = {"outlook", "gmail"}
|
_MAIL_SERVICES = {"outlook", "gmail"}
|
||||||
|
|
||||||
|
|
@ -1583,7 +1656,7 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
||||||
error="Provide either dataSourceId OR connectionId+service")
|
error="Provide either dataSourceId OR connectionId+service")
|
||||||
try:
|
try:
|
||||||
if dsId:
|
if dsId:
|
||||||
connectionId, service, basePath = await _resolveDataSource(dsId)
|
connectionId, service, basePath, _neutralize = await _resolveDataSource(dsId)
|
||||||
else:
|
else:
|
||||||
connectionId, service, basePath = directConnId, directService, args.get("path", "/")
|
connectionId, service, basePath = directConnId, directService, args.get("path", "/")
|
||||||
if subPath:
|
if subPath:
|
||||||
|
|
@ -1626,7 +1699,7 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
||||||
error="Provide either dataSourceId OR connectionId+service")
|
error="Provide either dataSourceId OR connectionId+service")
|
||||||
try:
|
try:
|
||||||
if dsId:
|
if dsId:
|
||||||
connectionId, service, basePath = await _resolveDataSource(dsId)
|
connectionId, service, basePath, _neutralize = await _resolveDataSource(dsId)
|
||||||
else:
|
else:
|
||||||
connectionId, service, basePath = directConnId, directService, args.get("path", "/")
|
connectionId, service, basePath = directConnId, directService, args.get("path", "/")
|
||||||
from modules.connectors.connectorResolver import ConnectorResolver
|
from modules.connectors.connectorResolver import ConnectorResolver
|
||||||
|
|
@ -1660,8 +1733,9 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
||||||
try:
|
try:
|
||||||
from modules.connectors.connectorResolver import ConnectorResolver
|
from modules.connectors.connectorResolver import ConnectorResolver
|
||||||
from modules.connectors.connectorProviderBase import DownloadResult as _DR
|
from modules.connectors.connectorProviderBase import DownloadResult as _DR
|
||||||
|
_sourceNeutralize = False
|
||||||
if dsId:
|
if dsId:
|
||||||
connectionId, service, basePath = await _resolveDataSource(dsId)
|
connectionId, service, basePath, _sourceNeutralize = await _resolveDataSource(dsId)
|
||||||
else:
|
else:
|
||||||
connectionId, service, basePath = directConnId, directService, "/"
|
connectionId, service, basePath = directConnId, directService, "/"
|
||||||
fullPath = filePath if filePath.startswith("/") else f"{basePath.rstrip('/')}/{filePath}"
|
fullPath = filePath if filePath.startswith("/") else f"{basePath.rstrip('/')}/{filePath}"
|
||||||
|
|
@ -1704,6 +1778,8 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
||||||
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "")
|
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "")
|
||||||
if fiId:
|
if fiId:
|
||||||
chatService.interfaceDbComponent.updateFile(fileItem.id, {"featureInstanceId": fiId})
|
chatService.interfaceDbComponent.updateFile(fileItem.id, {"featureInstanceId": fiId})
|
||||||
|
if _sourceNeutralize:
|
||||||
|
chatService.interfaceDbComponent.updateFile(fileItem.id, {"neutralize": True})
|
||||||
tempFolderId = _getOrCreateTempFolder(chatService)
|
tempFolderId = _getOrCreateTempFolder(chatService)
|
||||||
if tempFolderId:
|
if tempFolderId:
|
||||||
chatService.interfaceDbComponent.updateFile(fileItem.id, {"folderId": tempFolderId})
|
chatService.interfaceDbComponent.updateFile(fileItem.id, {"folderId": tempFolderId})
|
||||||
|
|
@ -2034,9 +2110,12 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
||||||
})
|
})
|
||||||
|
|
||||||
if contentObjects:
|
if contentObjects:
|
||||||
|
_diFiId, _diMId = _resolveFileScope(fileId, context)
|
||||||
await knowledgeService.indexFile(
|
await knowledgeService.indexFile(
|
||||||
fileId=fileId, fileName=fileName, mimeType=fileMime,
|
fileId=fileId, fileName=fileName, mimeType=fileMime,
|
||||||
userId=context.get("userId", ""), contentObjects=contentObjects,
|
userId=context.get("userId", ""), contentObjects=contentObjects,
|
||||||
|
featureInstanceId=_diFiId,
|
||||||
|
mandateId=_diMId,
|
||||||
)
|
)
|
||||||
|
|
||||||
chunks = knowledgeService._knowledgeDb.getContentChunks(fileId)
|
chunks = knowledgeService._knowledgeDb.getContentChunks(fileId)
|
||||||
|
|
@ -2082,9 +2161,22 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
||||||
dataUrl = f"data:{mimeType};base64,{imageData}"
|
dataUrl = f"data:{mimeType};base64,{imageData}"
|
||||||
from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum as OTE
|
from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum as OTE
|
||||||
|
|
||||||
|
_opType = OTE.IMAGE_ANALYSE
|
||||||
|
try:
|
||||||
|
from modules.datamodels.datamodelFiles import FileItem as _FileItemModel
|
||||||
|
from modules.interfaces.interfaceDbManagement import ComponentObjects as _CO
|
||||||
|
_fRow = _CO().db._loadRecord(_FileItemModel, fileId)
|
||||||
|
if _fRow:
|
||||||
|
_fGet = (lambda k, d=None: _fRow.get(k, d)) if isinstance(_fRow, dict) else (lambda k, d=None: getattr(_fRow, k, d))
|
||||||
|
if bool(_fGet("neutralize", False)):
|
||||||
|
_opType = OTE.NEUTRALIZATION_IMAGE
|
||||||
|
logger.info(f"describeImage: file {fileId} has neutralize=True, using NEUTRALIZATION_IMAGE (internal models only)")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
visionRequest = AiCallRequest(
|
visionRequest = AiCallRequest(
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
options=AiCallOptions(operationType=OTE.IMAGE_ANALYSE),
|
options=AiCallOptions(operationType=_opType),
|
||||||
messages=[{"role": "user", "content": [
|
messages=[{"role": "user", "content": [
|
||||||
{"type": "text", "text": prompt},
|
{"type": "text", "text": prompt},
|
||||||
{"type": "image_url", "image_url": {"url": dataUrl}},
|
{"type": "image_url", "image_url": {"url": dataUrl}},
|
||||||
|
|
@ -2517,55 +2609,55 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
||||||
|
|
||||||
if not voiceName:
|
if not voiceName:
|
||||||
try:
|
try:
|
||||||
from modules.features.workspace import interfaceFeatureWorkspace
|
from modules.datamodels.datamodelUam import UserVoicePreferences
|
||||||
featureInstanceId = context.get("featureInstanceId", "")
|
from modules.security.rootAccess import getRootInterface
|
||||||
userId = context.get("userId", "")
|
userId = context.get("userId", "")
|
||||||
if userId:
|
if userId:
|
||||||
wsIf = interfaceFeatureWorkspace.getInterface(
|
rootIf = getRootInterface()
|
||||||
services.user,
|
prefRecords = rootIf.db.getRecordset(
|
||||||
mandateId=mandateId or None,
|
UserVoicePreferences,
|
||||||
featureInstanceId=featureInstanceId or None,
|
recordFilter={"userId": userId, "mandateId": mandateId}
|
||||||
)
|
)
|
||||||
vs = wsIf.getVoiceSettings(userId) if wsIf else None
|
if not prefRecords and mandateId:
|
||||||
if vs:
|
prefRecords = rootIf.db.getRecordset(
|
||||||
voiceMap = {}
|
UserVoicePreferences,
|
||||||
if hasattr(vs, "ttsVoiceMap") and vs.ttsVoiceMap:
|
recordFilter={"userId": userId}
|
||||||
voiceMap = vs.ttsVoiceMap if isinstance(vs.ttsVoiceMap, dict) else {}
|
)
|
||||||
|
if prefRecords:
|
||||||
|
vs = prefRecords[0] if isinstance(prefRecords[0], dict) else prefRecords[0].model_dump() if hasattr(prefRecords[0], "model_dump") else prefRecords[0]
|
||||||
|
voiceMap = vs.get("ttsVoiceMap", {}) or {}
|
||||||
|
if isinstance(voiceMap, dict) and voiceMap:
|
||||||
|
selectedKey = None
|
||||||
|
selectedVoiceEntry = None
|
||||||
|
baseLanguage = language.split("-")[0].lower() if isinstance(language, str) and language else ""
|
||||||
|
|
||||||
selectedKey = None
|
if isinstance(language, str) and language in voiceMap:
|
||||||
selectedVoiceEntry = None
|
selectedKey = language
|
||||||
baseLanguage = language.split("-")[0].lower() if isinstance(language, str) and language else ""
|
selectedVoiceEntry = voiceMap[language]
|
||||||
|
|
||||||
# 1) Exact match first (e.g. de-DE)
|
if selectedVoiceEntry is None and baseLanguage and baseLanguage in voiceMap:
|
||||||
if isinstance(language, str) and language in voiceMap:
|
selectedKey = baseLanguage
|
||||||
selectedKey = language
|
selectedVoiceEntry = voiceMap[baseLanguage]
|
||||||
selectedVoiceEntry = voiceMap[language]
|
|
||||||
|
|
||||||
# 2) Match short language key (e.g. de)
|
if selectedVoiceEntry is None and baseLanguage:
|
||||||
if selectedVoiceEntry is None and baseLanguage and baseLanguage in voiceMap:
|
for mapKey, mapValue in voiceMap.items():
|
||||||
selectedKey = baseLanguage
|
mapKeyNorm = str(mapKey).lower()
|
||||||
selectedVoiceEntry = voiceMap[baseLanguage]
|
if mapKeyNorm == baseLanguage or mapKeyNorm.startswith(f"{baseLanguage}-"):
|
||||||
|
selectedKey = str(mapKey)
|
||||||
|
selectedVoiceEntry = mapValue
|
||||||
|
break
|
||||||
|
|
||||||
# 3) Match by same language family (e.g. de-CH -> de-DE mapping)
|
if selectedVoiceEntry is not None:
|
||||||
if selectedVoiceEntry is None and baseLanguage:
|
voiceName = (
|
||||||
for mapKey, mapValue in voiceMap.items():
|
selectedVoiceEntry.get("voiceName")
|
||||||
mapKeyNorm = str(mapKey).lower()
|
if isinstance(selectedVoiceEntry, dict)
|
||||||
if mapKeyNorm == baseLanguage or mapKeyNorm.startswith(f"{baseLanguage}-"):
|
else selectedVoiceEntry
|
||||||
selectedKey = str(mapKey)
|
)
|
||||||
selectedVoiceEntry = mapValue
|
logger.info(
|
||||||
break
|
f"textToSpeech: using configured voice '{voiceName}' for requested language '{language}' (matched key '{selectedKey}')"
|
||||||
|
)
|
||||||
if selectedVoiceEntry is not None:
|
if not voiceName and vs.get("ttsVoice") and vs.get("ttsLanguage") == language:
|
||||||
voiceName = (
|
voiceName = vs["ttsVoice"]
|
||||||
selectedVoiceEntry.get("voiceName")
|
|
||||||
if isinstance(selectedVoiceEntry, dict)
|
|
||||||
else selectedVoiceEntry
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
f"textToSpeech: using configured voice '{voiceName}' for requested language '{language}' (matched key '{selectedKey}')"
|
|
||||||
)
|
|
||||||
elif hasattr(vs, "ttsVoice") and vs.ttsVoice and hasattr(vs, "ttsLanguage") and vs.ttsLanguage == language:
|
|
||||||
voiceName = vs.ttsVoice
|
|
||||||
except Exception as prefErr:
|
except Exception as prefErr:
|
||||||
logger.debug(f"textToSpeech: could not load voice preferences: {prefErr}")
|
logger.debug(f"textToSpeech: could not load voice preferences: {prefErr}")
|
||||||
|
|
||||||
|
|
@ -2963,7 +3055,7 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
||||||
if not neutralizationService.interfaceDbComponent:
|
if not neutralizationService.interfaceDbComponent:
|
||||||
neutralizationService.interfaceDbComponent = services.chat.interfaceDbComponent
|
neutralizationService.interfaceDbComponent = services.chat.interfaceDbComponent
|
||||||
if text:
|
if text:
|
||||||
result = neutralizationService.processText(text)
|
result = await neutralizationService.processTextAsync(text, fileId or None)
|
||||||
else:
|
else:
|
||||||
result = neutralizationService.processFile(fileId)
|
result = neutralizationService.processFile(fileId)
|
||||||
if result:
|
if result:
|
||||||
|
|
@ -3093,6 +3185,11 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
||||||
recordFilter={"featureInstanceId": featureInstanceId, "workspaceInstanceId": workspaceInstanceId},
|
recordFilter={"featureInstanceId": featureInstanceId, "workspaceInstanceId": workspaceInstanceId},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_anySourceNeutralize = any(
|
||||||
|
bool(ds.get("neutralize", False) if isinstance(ds, dict) else getattr(ds, "neutralize", False))
|
||||||
|
for ds in (featureDataSources or [])
|
||||||
|
)
|
||||||
|
|
||||||
from modules.security.rbacCatalog import getCatalogService
|
from modules.security.rbacCatalog import getCatalogService
|
||||||
catalog = getCatalogService()
|
catalog = getCatalogService()
|
||||||
if not featureDataSources:
|
if not featureDataSources:
|
||||||
|
|
@ -3127,6 +3224,8 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _subAgentAiCall(req):
|
async def _subAgentAiCall(req):
|
||||||
|
if _anySourceNeutralize:
|
||||||
|
req.requireNeutralization = True
|
||||||
return await aiService.callAi(req)
|
return await aiService.callAi(req)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -3188,7 +3287,7 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
||||||
allWorkflows = chatInterface.getWorkflows() or []
|
allWorkflows = chatInterface.getWorkflows() or []
|
||||||
|
|
||||||
allWorkflows.sort(
|
allWorkflows.sort(
|
||||||
key=lambda w: w.get("_createdAt") or w.get("startedAt") or 0,
|
key=lambda w: w.get("sysCreatedAt") or w.get("startedAt") or 0,
|
||||||
reverse=True,
|
reverse=True,
|
||||||
)
|
)
|
||||||
allWorkflows = allWorkflows[:50]
|
allWorkflows = allWorkflows[:50]
|
||||||
|
|
@ -3197,7 +3296,7 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
||||||
for wf in allWorkflows:
|
for wf in allWorkflows:
|
||||||
wfId = wf.get("id", "")
|
wfId = wf.get("id", "")
|
||||||
name = wf.get("name") or "(unnamed)"
|
name = wf.get("name") or "(unnamed)"
|
||||||
createdAt = wf.get("_createdAt") or wf.get("startedAt") or 0
|
createdAt = wf.get("sysCreatedAt") or wf.get("startedAt") or 0
|
||||||
lastActivity = wf.get("lastActivity") or createdAt
|
lastActivity = wf.get("lastActivity") or createdAt
|
||||||
|
|
||||||
msgs = chatInterface.getMessages(wfId) or []
|
msgs = chatInterface.getMessages(wfId) or []
|
||||||
|
|
@ -3275,7 +3374,7 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
||||||
items.append({
|
items.append({
|
||||||
"role": raw.get("role", ""),
|
"role": raw.get("role", ""),
|
||||||
"message": content,
|
"message": content,
|
||||||
"publishedAt": raw.get("publishedAt") or raw.get("_createdAt") or 0,
|
"publishedAt": raw.get("publishedAt") or raw.get("sysCreatedAt") or 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
header = f"Workflow {targetWorkflowId}: {len(allMsgs)} total messages"
|
header = f"Workflow {targetWorkflowId}: {len(allMsgs)} total messages"
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ 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 (
|
from modules.serviceCenter.services.serviceBilling.billingExhaustedNotify import (
|
||||||
maybeEmailMandatePoolExhausted,
|
maybeEmailMandatePoolExhausted,
|
||||||
)
|
)
|
||||||
|
|
@ -78,6 +77,9 @@ class _ServicesAdapter:
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def featureCode(self) -> Optional[str]:
|
def featureCode(self) -> Optional[str]:
|
||||||
|
fc = getattr(self._context, "feature_code", None)
|
||||||
|
if fc and str(fc).strip():
|
||||||
|
return str(fc).strip()
|
||||||
w = self.workflow
|
w = self.workflow
|
||||||
if w and hasattr(w, "feature") and w.feature:
|
if w and hasattr(w, "feature") and w.feature:
|
||||||
return getattr(w.feature, "code", None)
|
return getattr(w.feature, "code", None)
|
||||||
|
|
@ -153,6 +155,9 @@ class AiService:
|
||||||
2. Balance & provider check before AI call
|
2. Balance & provider check before AI call
|
||||||
3. billingCallback on aiObjects: records one billing transaction per model call
|
3. billingCallback on aiObjects: records one billing transaction per model call
|
||||||
with exact provider + model name (set before AI call, invoked by _callWithModel)
|
with exact provider + model name (set before AI call, invoked by _callWithModel)
|
||||||
|
|
||||||
|
NEUTRALIZATION: If enabled, prompt text is neutralized before the AI call
|
||||||
|
and placeholders in the response are rehydrated afterwards.
|
||||||
"""
|
"""
|
||||||
await self.ensureAiObjectsInitialized()
|
await self.ensureAiObjectsInitialized()
|
||||||
|
|
||||||
|
|
@ -172,9 +177,15 @@ class AiService:
|
||||||
request.options = request.options.model_copy(update={'allowedProviders': effectiveProviders})
|
request.options = request.options.model_copy(update={'allowedProviders': effectiveProviders})
|
||||||
logger.debug(f"Effective allowedProviders for AI request: {effectiveProviders}")
|
logger.debug(f"Effective allowedProviders for AI request: {effectiveProviders}")
|
||||||
|
|
||||||
# Set billing callback on aiObjects BEFORE the AI call
|
# Neutralize prompt if enabled (before AI call)
|
||||||
# This callback is invoked by _callWithModel() after EVERY individual model call
|
_wasNeutralized = False
|
||||||
# For parallel content parts (e.g., 200 MB doc), each model call creates its own transaction
|
_excludedDocs: List[str] = []
|
||||||
|
if self._shouldNeutralize(request):
|
||||||
|
request, _wasNeutralized, _excludedDocs = await self._neutralizeRequest(request)
|
||||||
|
if _excludedDocs:
|
||||||
|
logger.warning(f"Neutralization partial failures (continuing): {_excludedDocs}")
|
||||||
|
|
||||||
|
logger.debug("callAi: neutralization phase done, starting main AI call")
|
||||||
self.aiObjects.billingCallback = self._createBillingCallback()
|
self.aiObjects.billingCallback = self._createBillingCallback()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -187,10 +198,23 @@ class AiService:
|
||||||
finally:
|
finally:
|
||||||
self.aiObjects.billingCallback = None
|
self.aiObjects.billingCallback = None
|
||||||
|
|
||||||
|
# Attach neutralization exclusion metadata if any parts failed
|
||||||
|
if _excludedDocs and response:
|
||||||
|
if not hasattr(response, 'metadata') or response.metadata is None:
|
||||||
|
response.metadata = {}
|
||||||
|
if isinstance(response.metadata, dict):
|
||||||
|
response.metadata["neutralizationExcluded"] = _excludedDocs
|
||||||
|
elif hasattr(response.metadata, '__dict__'):
|
||||||
|
response.metadata.neutralizationExcluded = _excludedDocs
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
async def callAiStream(self, request: AiCallRequest):
|
async def callAiStream(self, request: AiCallRequest):
|
||||||
"""Streaming variant of callAi. Yields str deltas during generation, then final AiCallResponse."""
|
"""Streaming variant of callAi. Yields str deltas during generation, then final AiCallResponse.
|
||||||
|
|
||||||
|
NEUTRALIZATION: If enabled, prompt text is neutralized before streaming.
|
||||||
|
Rehydration happens on the final AiCallResponse (not on individual str deltas).
|
||||||
|
"""
|
||||||
await self.ensureAiObjectsInitialized()
|
await self.ensureAiObjectsInitialized()
|
||||||
self._preflightBillingCheck()
|
self._preflightBillingCheck()
|
||||||
await self._checkBillingBeforeAiCall()
|
await self._checkBillingBeforeAiCall()
|
||||||
|
|
@ -199,9 +223,26 @@ class AiService:
|
||||||
if effectiveProviders and request.options:
|
if effectiveProviders and request.options:
|
||||||
request.options = request.options.model_copy(update={'allowedProviders': effectiveProviders})
|
request.options = request.options.model_copy(update={'allowedProviders': effectiveProviders})
|
||||||
|
|
||||||
|
# Neutralize prompt if enabled (before streaming)
|
||||||
|
_wasNeutralized = False
|
||||||
|
_excludedDocs: List[str] = []
|
||||||
|
if self._shouldNeutralize(request):
|
||||||
|
request, _wasNeutralized, _excludedDocs = await self._neutralizeRequest(request)
|
||||||
|
if _excludedDocs:
|
||||||
|
logger.warning(f"Neutralization partial failures in stream (continuing): {_excludedDocs}")
|
||||||
|
|
||||||
|
logger.debug("callAiStream: neutralization phase done, starting main AI stream")
|
||||||
self.aiObjects.billingCallback = self._createBillingCallback()
|
self.aiObjects.billingCallback = self._createBillingCallback()
|
||||||
try:
|
try:
|
||||||
async for chunk in self.aiObjects.callWithTextContextStream(request):
|
async for chunk in self.aiObjects.callWithTextContextStream(request):
|
||||||
|
if not isinstance(chunk, str):
|
||||||
|
if _excludedDocs:
|
||||||
|
if not hasattr(chunk, 'metadata') or chunk.metadata is None:
|
||||||
|
chunk.metadata = {}
|
||||||
|
if isinstance(chunk.metadata, dict):
|
||||||
|
chunk.metadata["neutralizationExcluded"] = _excludedDocs
|
||||||
|
elif hasattr(chunk.metadata, '__dict__'):
|
||||||
|
chunk.metadata.neutralizationExcluded = _excludedDocs
|
||||||
yield chunk
|
yield chunk
|
||||||
finally:
|
finally:
|
||||||
self.aiObjects.billingCallback = None
|
self.aiObjects.billingCallback = None
|
||||||
|
|
@ -511,6 +552,318 @@ detectedIntent-Werte:
|
||||||
|
|
||||||
return basePrompt
|
return basePrompt
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# NEUTRALIZATION: Centralized prompt neutralization / response rehydration
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def _hasNeutralizationModel(self) -> bool:
|
||||||
|
"""Fast check: is at least one model available for NEUTRALIZATION_TEXT
|
||||||
|
given the current effective provider list? No AI call is made."""
|
||||||
|
try:
|
||||||
|
from modules.aicore.aicoreModelRegistry import modelRegistry
|
||||||
|
from modules.aicore.aicoreModelSelector import modelSelector as _modSel
|
||||||
|
from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum
|
||||||
|
|
||||||
|
_models = modelRegistry.getAvailableModels()
|
||||||
|
_providers = self._calculateEffectiveProviders()
|
||||||
|
if _providers:
|
||||||
|
_models = [m for m in _models if m.connectorType in _providers]
|
||||||
|
_opts = AiCallOptions(operationType=OperationTypeEnum.NEUTRALIZATION_TEXT)
|
||||||
|
_failover = _modSel.getFailoverModelList("x", "", _opts, _models)
|
||||||
|
return bool(_failover)
|
||||||
|
except Exception as _e:
|
||||||
|
logger.warning(f"_hasNeutralizationModel check failed: {_e}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _shouldNeutralize(self, request: AiCallRequest) -> bool:
|
||||||
|
"""Check if this AI request should have neutralization applied.
|
||||||
|
|
||||||
|
OR-logic across three sources (any True → neutralize):
|
||||||
|
1. Feature-Instance config (NeutralizationConfig.enabled)
|
||||||
|
2. Workflow/Session (context.requireNeutralization)
|
||||||
|
3. Per-request (request.requireNeutralization)
|
||||||
|
|
||||||
|
No source can override another's True with False.
|
||||||
|
Neutralization calls themselves (NEUTRALIZATION_TEXT / NEUTRALIZATION_IMAGE)
|
||||||
|
are never re-neutralized (recursion guard).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not request.prompt and not request.messages and not request.context:
|
||||||
|
return False
|
||||||
|
|
||||||
|
_opType = request.options.operationType if request.options else None
|
||||||
|
if _opType in (OperationTypeEnum.NEUTRALIZATION_TEXT, OperationTypeEnum.NEUTRALIZATION_IMAGE):
|
||||||
|
return False
|
||||||
|
|
||||||
|
_sources = []
|
||||||
|
|
||||||
|
# Source 1: Feature-Instance config
|
||||||
|
_neutralSvc = self._get_service("neutralization")
|
||||||
|
if _neutralSvc and hasattr(_neutralSvc, 'getConfig'):
|
||||||
|
_config = _neutralSvc.getConfig()
|
||||||
|
if _config and getattr(_config, 'enabled', False):
|
||||||
|
_sources.append("featureInstance")
|
||||||
|
|
||||||
|
# Source 2: Workflow / Session context
|
||||||
|
_ctx = getattr(self.services, '_context', None)
|
||||||
|
_ctxFlag = getattr(_ctx, "requireNeutralization", None) if _ctx else None
|
||||||
|
if _ctxFlag is True:
|
||||||
|
_sources.append("context")
|
||||||
|
|
||||||
|
# Source 3: Per-request flag
|
||||||
|
if request.requireNeutralization is True:
|
||||||
|
_sources.append("request")
|
||||||
|
|
||||||
|
if _sources:
|
||||||
|
logger.debug(f"Neutralization required by: {', '.join(_sources)}")
|
||||||
|
request.requireNeutralization = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"_shouldNeutralize check failed: {e} — defaulting to False")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _neutralizeRequest(self, request: AiCallRequest) -> Tuple[AiCallRequest, bool, List[str]]:
|
||||||
|
"""Neutralize the prompt text and messages in an AiCallRequest (async).
|
||||||
|
|
||||||
|
Returns (modifiedRequest, wasNeutralized, excludedDocs).
|
||||||
|
|
||||||
|
Uses ``processTextAsync`` which calls AI with NEUTRALIZATION_TEXT
|
||||||
|
to identify PII, protected logic and names — then applies regex as
|
||||||
|
supplementary pass.
|
||||||
|
|
||||||
|
FAILSAFE behaviour when ``requireNeutralization is True`` (explicit):
|
||||||
|
- Service unavailable → raises (caller must not send raw data to AI).
|
||||||
|
- Prompt neutralization fails → raises.
|
||||||
|
- Individual message neutralization fails → message is **removed**
|
||||||
|
(not kept in original form) and noted in excludedDocs.
|
||||||
|
|
||||||
|
When neutralization is only config-driven (requireNeutralization is
|
||||||
|
None) the behaviour is softer: failures are logged and originals are
|
||||||
|
kept — but a warning is emitted.
|
||||||
|
"""
|
||||||
|
_hardMode = request.requireNeutralization is True
|
||||||
|
excludedDocs: List[str] = []
|
||||||
|
|
||||||
|
neutralSvc = self._get_service("neutralization")
|
||||||
|
if not neutralSvc or not hasattr(neutralSvc, 'processTextAsync'):
|
||||||
|
if _hardMode:
|
||||||
|
raise RuntimeError("Neutralization explicitly required but service unavailable — AI call BLOCKED")
|
||||||
|
logger.warning("Neutralization required by config but service unavailable — continuing without neutralization")
|
||||||
|
excludedDocs.append("Neutralization service unavailable; prompt sent un-neutralized")
|
||||||
|
return request, False, excludedDocs
|
||||||
|
|
||||||
|
_wasNeutralized = False
|
||||||
|
_snapshots: list = []
|
||||||
|
|
||||||
|
if _hardMode:
|
||||||
|
_hasNeutModel = await self._hasNeutralizationModel()
|
||||||
|
if not _hasNeutModel:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Neutralisierung ist aktiviert, aber es ist kein AI-Modell für "
|
||||||
|
"NEUTRALIZATION_TEXT verfügbar. Bitte ein Modell für Neutralisierung "
|
||||||
|
"freigeben oder die Neutralisierung deaktivieren."
|
||||||
|
)
|
||||||
|
|
||||||
|
if request.prompt:
|
||||||
|
logger.debug(f"_neutralizeRequest: neutralizing prompt ({len(request.prompt)} chars)")
|
||||||
|
try:
|
||||||
|
result = await neutralSvc.processTextAsync(request.prompt)
|
||||||
|
if result and result.get("neutralized_text"):
|
||||||
|
request.prompt = result["neutralized_text"]
|
||||||
|
_wasNeutralized = True
|
||||||
|
_snapshots.append(("Prompt", result["neutralized_text"], len(result.get("mapping", {}))))
|
||||||
|
logger.debug("Neutralized prompt in AiCallRequest")
|
||||||
|
else:
|
||||||
|
if _hardMode:
|
||||||
|
raise RuntimeError(f"Prompt neutralization returned empty — AI call BLOCKED (hard mode)")
|
||||||
|
logger.warning("Neutralization of prompt returned no neutralized_text — sending original prompt")
|
||||||
|
excludedDocs.append("Prompt neutralization failed; original prompt used")
|
||||||
|
except RuntimeError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
if _hardMode:
|
||||||
|
raise RuntimeError(f"Prompt neutralization failed — AI call BLOCKED: {e}") from e
|
||||||
|
logger.warning(f"Neutralization of prompt failed: {e} — sending original prompt")
|
||||||
|
excludedDocs.append(f"Prompt neutralization error: {e}")
|
||||||
|
|
||||||
|
if request.context:
|
||||||
|
logger.debug(f"_neutralizeRequest: neutralizing context ({len(request.context)} chars)")
|
||||||
|
try:
|
||||||
|
result = await neutralSvc.processTextAsync(request.context)
|
||||||
|
if result and result.get("neutralized_text"):
|
||||||
|
request.context = result["neutralized_text"]
|
||||||
|
_wasNeutralized = True
|
||||||
|
_snapshots.append(("Kontext", result["neutralized_text"], len(result.get("mapping", {}))))
|
||||||
|
logger.debug("Neutralized context in AiCallRequest")
|
||||||
|
else:
|
||||||
|
if _hardMode:
|
||||||
|
raise RuntimeError("Context neutralization returned empty — AI call BLOCKED (hard mode)")
|
||||||
|
logger.warning("Neutralization of context returned no neutralized_text — sending original context")
|
||||||
|
excludedDocs.append("Context neutralization failed; original context used")
|
||||||
|
except RuntimeError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
if _hardMode:
|
||||||
|
raise RuntimeError(f"Context neutralization failed — AI call BLOCKED: {e}") from e
|
||||||
|
logger.warning(f"Neutralization of context failed: {e} — sending original context")
|
||||||
|
excludedDocs.append(f"Context neutralization error: {e}")
|
||||||
|
|
||||||
|
_msgCount = len(request.messages) if request.messages and isinstance(request.messages, list) else 0
|
||||||
|
if _msgCount:
|
||||||
|
logger.debug(f"_neutralizeRequest: neutralizing {_msgCount} message(s)")
|
||||||
|
if request.messages and isinstance(request.messages, list):
|
||||||
|
cleanMessages = []
|
||||||
|
for idx, msg in enumerate(request.messages):
|
||||||
|
content = msg.get("content") if isinstance(msg, dict) else None
|
||||||
|
if content is None:
|
||||||
|
cleanMessages.append(msg)
|
||||||
|
continue
|
||||||
|
if isinstance(content, str):
|
||||||
|
if not content:
|
||||||
|
cleanMessages.append(msg)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
result = await neutralSvc.processTextAsync(content)
|
||||||
|
if result and result.get("neutralized_text"):
|
||||||
|
msg["content"] = result["neutralized_text"]
|
||||||
|
_wasNeutralized = True
|
||||||
|
_role = msg.get("role", "?")
|
||||||
|
_snapshots.append((f"Nachricht {idx+1} ({_role})", result["neutralized_text"], len(result.get("mapping", {}))))
|
||||||
|
cleanMessages.append(msg)
|
||||||
|
else:
|
||||||
|
if _hardMode:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Neutralisierung von Nachricht {idx+1}/{_msgCount} schlug fehl "
|
||||||
|
f"(leere Antwort). Konversation kann nicht sicher gesendet werden."
|
||||||
|
)
|
||||||
|
logger.warning(f"Neutralization of message[{idx}] returned no neutralized_text — keeping original")
|
||||||
|
excludedDocs.append(f"Message[{idx}] neutralization failed; original kept")
|
||||||
|
cleanMessages.append(msg)
|
||||||
|
except RuntimeError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
if _hardMode:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Neutralisierung von Nachricht {idx+1}/{_msgCount} schlug fehl: {e}. "
|
||||||
|
f"Konversation kann nicht sicher gesendet werden."
|
||||||
|
) from e
|
||||||
|
logger.warning(f"Neutralization of message[{idx}] failed: {e} — keeping original")
|
||||||
|
excludedDocs.append(f"Message[{idx}] neutralization error: {e}")
|
||||||
|
cleanMessages.append(msg)
|
||||||
|
elif isinstance(content, list):
|
||||||
|
_cleanParts = []
|
||||||
|
for _partIdx, _part in enumerate(content):
|
||||||
|
if not isinstance(_part, dict):
|
||||||
|
_cleanParts.append(_part)
|
||||||
|
continue
|
||||||
|
_partType = _part.get("type", "")
|
||||||
|
if _partType == "text" and _part.get("text"):
|
||||||
|
try:
|
||||||
|
_result = await neutralSvc.processTextAsync(_part["text"])
|
||||||
|
if _result and _result.get("neutralized_text"):
|
||||||
|
_part["text"] = _result["neutralized_text"]
|
||||||
|
_wasNeutralized = True
|
||||||
|
_role = msg.get("role", "?")
|
||||||
|
_snapshots.append((f"Nachricht {idx+1}.{_partIdx+1} ({_role})", _result["neutralized_text"], len(_result.get("mapping", {}))))
|
||||||
|
_cleanParts.append(_part)
|
||||||
|
else:
|
||||||
|
if _hardMode:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Neutralisierung von Nachricht {idx+1}, Teil {_partIdx+1} "
|
||||||
|
f"schlug fehl (leere Antwort)."
|
||||||
|
)
|
||||||
|
_cleanParts.append(_part)
|
||||||
|
except RuntimeError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
if _hardMode:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Neutralisierung von Nachricht {idx+1}, Teil {_partIdx+1} "
|
||||||
|
f"schlug fehl: {e}"
|
||||||
|
) from e
|
||||||
|
_cleanParts.append(_part)
|
||||||
|
elif _partType == "image_url":
|
||||||
|
if _hardMode:
|
||||||
|
logger.warning(f"Message[{idx}].content[{_partIdx}] image_url — REMOVING (neutralization active)")
|
||||||
|
excludedDocs.append(f"Message[{idx}].content[{_partIdx}] image removed (neutralization)")
|
||||||
|
else:
|
||||||
|
_cleanParts.append(_part)
|
||||||
|
else:
|
||||||
|
_cleanParts.append(_part)
|
||||||
|
if _cleanParts:
|
||||||
|
msg["content"] = _cleanParts
|
||||||
|
cleanMessages.append(msg)
|
||||||
|
else:
|
||||||
|
cleanMessages.append(msg)
|
||||||
|
else:
|
||||||
|
cleanMessages.append(msg)
|
||||||
|
request.messages = cleanMessages
|
||||||
|
logger.debug(f"_neutralizeRequest: messages done, {len(cleanMessages)} kept of {_msgCount}")
|
||||||
|
|
||||||
|
if hasattr(request, 'contentParts') and request.contentParts:
|
||||||
|
_cleanParts = []
|
||||||
|
for _cpIdx, _cp in enumerate(request.contentParts):
|
||||||
|
_tg = getattr(_cp, 'typeGroup', '') or ''
|
||||||
|
_data = getattr(_cp, 'data', '') or ''
|
||||||
|
if _tg in ('text', 'table') and _data:
|
||||||
|
try:
|
||||||
|
_result = await neutralSvc.processTextAsync(str(_data))
|
||||||
|
if _result and _result.get("neutralized_text"):
|
||||||
|
_cp.data = _result["neutralized_text"]
|
||||||
|
_wasNeutralized = True
|
||||||
|
_snapshots.append((f"Inhalt {_cpIdx+1} ({_tg})", _result["neutralized_text"], len(_result.get("mapping", {}))))
|
||||||
|
_cleanParts.append(_cp)
|
||||||
|
else:
|
||||||
|
if _hardMode:
|
||||||
|
logger.warning(f"ContentPart[{_cpIdx}] neutralization empty — REMOVING")
|
||||||
|
excludedDocs.append(f"ContentPart[{_cpIdx}] removed")
|
||||||
|
else:
|
||||||
|
_cleanParts.append(_cp)
|
||||||
|
except Exception as e:
|
||||||
|
if _hardMode:
|
||||||
|
logger.warning(f"ContentPart[{_cpIdx}] neutralization error — REMOVING: {e}")
|
||||||
|
excludedDocs.append(f"ContentPart[{_cpIdx}] error: {e}")
|
||||||
|
else:
|
||||||
|
_cleanParts.append(_cp)
|
||||||
|
elif _tg == 'image':
|
||||||
|
if _hardMode:
|
||||||
|
logger.warning(f"ContentPart[{_cpIdx}] image — REMOVING (neutralization active)")
|
||||||
|
excludedDocs.append(f"ContentPart[{_cpIdx}] image removed")
|
||||||
|
else:
|
||||||
|
_cleanParts.append(_cp)
|
||||||
|
else:
|
||||||
|
_cleanParts.append(_cp)
|
||||||
|
request.contentParts = _cleanParts
|
||||||
|
logger.debug(f"_neutralizeRequest: contentParts done, {len(_cleanParts)} kept")
|
||||||
|
|
||||||
|
if _snapshots and _wasNeutralized:
|
||||||
|
try:
|
||||||
|
neutralSvc.clearSnapshots()
|
||||||
|
for _label, _text, _phCount in _snapshots:
|
||||||
|
neutralSvc.saveSnapshot(_label, _text, _phCount)
|
||||||
|
logger.debug(f"_neutralizeRequest: saved {len(_snapshots)} snapshot(s)")
|
||||||
|
except Exception as _snapErr:
|
||||||
|
logger.warning(f"_neutralizeRequest: could not save snapshots: {_snapErr}")
|
||||||
|
|
||||||
|
logger.info(f"_neutralizeRequest complete: neutralized={_wasNeutralized}, excluded={len(excludedDocs)}")
|
||||||
|
return request, _wasNeutralized, excludedDocs
|
||||||
|
|
||||||
|
def _rehydrateResponse(self, responseText: str) -> str:
|
||||||
|
"""Replace neutralization placeholders with original values in AI response."""
|
||||||
|
if not responseText:
|
||||||
|
return responseText
|
||||||
|
try:
|
||||||
|
neutralSvc = self._get_service("neutralization")
|
||||||
|
if not neutralSvc or not hasattr(neutralSvc, 'resolveText'):
|
||||||
|
return responseText
|
||||||
|
resolved = neutralSvc.resolveText(responseText)
|
||||||
|
return resolved if resolved else responseText
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Response rehydration failed: {e}")
|
||||||
|
return responseText
|
||||||
|
|
||||||
def _preflightBillingCheck(self) -> None:
|
def _preflightBillingCheck(self) -> None:
|
||||||
"""
|
"""
|
||||||
Pre-flight billing validation - like a 0 CHF credit card authorization check.
|
Pre-flight billing validation - like a 0 CHF credit card authorization check.
|
||||||
|
|
@ -610,19 +963,17 @@ detectedIntent-Werte:
|
||||||
|
|
||||||
balance_str = f"{(balanceCheck.currentBalance or 0):.2f}"
|
balance_str = f"{(balanceCheck.currentBalance or 0):.2f}"
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Billing check failed for user {user.id}: "
|
f"AI billing check failed (mandate pool): mandate={mandateId} user={user.id} "
|
||||||
f"Balance {balance_str} CHF, "
|
f"poolBalance={balance_str} CHF required~={estimatedCost:.4f} CHF reason={reason}"
|
||||||
f"Reason: {reason}"
|
)
|
||||||
|
ulabel = (getattr(user, "email", None) or getattr(user, "username", None) or str(user.id))
|
||||||
|
maybeEmailMandatePoolExhausted(
|
||||||
|
str(mandateId),
|
||||||
|
str(user.id),
|
||||||
|
str(ulabel),
|
||||||
|
float(balanceCheck.currentBalance or 0.0),
|
||||||
|
float(estimatedCost),
|
||||||
)
|
)
|
||||||
if balanceCheck.billingModel == BillingModelEnum.PREPAY_MANDATE:
|
|
||||||
ulabel = (getattr(user, "email", None) or getattr(user, "username", None) or str(user.id))
|
|
||||||
maybeEmailMandatePoolExhausted(
|
|
||||||
str(mandateId),
|
|
||||||
str(user.id),
|
|
||||||
str(ulabel),
|
|
||||||
float(balanceCheck.currentBalance or 0.0),
|
|
||||||
float(estimatedCost),
|
|
||||||
)
|
|
||||||
raise InsufficientBalanceException.fromBalanceCheck(
|
raise InsufficientBalanceException.fromBalanceCheck(
|
||||||
balanceCheck,
|
balanceCheck,
|
||||||
str(mandateId),
|
str(mandateId),
|
||||||
|
|
|
||||||
|
|
@ -16,13 +16,11 @@ from datetime import datetime
|
||||||
|
|
||||||
from modules.datamodels.datamodelUam import User
|
from modules.datamodels.datamodelUam import User
|
||||||
from modules.datamodels.datamodelBilling import (
|
from modules.datamodels.datamodelBilling import (
|
||||||
BillingModelEnum,
|
|
||||||
BillingCheckResult,
|
BillingCheckResult,
|
||||||
TransactionTypeEnum,
|
TransactionTypeEnum,
|
||||||
ReferenceTypeEnum,
|
ReferenceTypeEnum,
|
||||||
BillingTransaction,
|
BillingTransaction,
|
||||||
BillingBalanceResponse,
|
BillingBalanceResponse,
|
||||||
parseBillingModelFromStoredValue,
|
|
||||||
)
|
)
|
||||||
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
|
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
|
||||||
|
|
||||||
|
|
@ -60,6 +58,9 @@ def getService(currentUser: User, mandateId: str, featureInstanceId: str = None,
|
||||||
|
|
||||||
def _get_feature_code_from_context(context) -> Optional[str]:
|
def _get_feature_code_from_context(context) -> Optional[str]:
|
||||||
"""Extract featureCode from ServiceCenterContext."""
|
"""Extract featureCode from ServiceCenterContext."""
|
||||||
|
explicit = getattr(context, "feature_code", None)
|
||||||
|
if explicit and str(explicit).strip():
|
||||||
|
return str(explicit).strip()
|
||||||
if context.workflow and hasattr(context.workflow, "feature") and context.workflow.feature:
|
if context.workflow and hasattr(context.workflow, "feature") and context.workflow.feature:
|
||||||
return getattr(context.workflow.feature, "code", None)
|
return getattr(context.workflow.feature, "code", None)
|
||||||
return getattr(context.workflow, "featureCode", None) if context.workflow else None
|
return getattr(context.workflow, "featureCode", None) if context.workflow else None
|
||||||
|
|
@ -369,20 +370,10 @@ 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 = parseBillingModelFromStoredValue(settings.get("billingModel"))
|
account = self._billingInterface.getOrCreateMandateAccount(
|
||||||
|
self.mandateId,
|
||||||
# Get or create account
|
initialBalance=0.0
|
||||||
if billingModel == BillingModelEnum.PREPAY_USER:
|
)
|
||||||
account = self._billingInterface.getOrCreateUserAccount(
|
|
||||||
self.mandateId,
|
|
||||||
self.currentUser.id,
|
|
||||||
initialBalance=0.0
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
account = self._billingInterface.getOrCreateMandateAccount(
|
|
||||||
self.mandateId,
|
|
||||||
initialBalance=0.0
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create credit transaction
|
# Create credit transaction
|
||||||
transaction = BillingTransaction(
|
transaction = BillingTransaction(
|
||||||
|
|
@ -429,45 +420,32 @@ BILLING_USER_ACTION_TOP_UP_SELF = "TOP_UP_SELF"
|
||||||
BILLING_USER_ACTION_CONTACT_MANDATE_ADMIN = "CONTACT_MANDATE_ADMIN"
|
BILLING_USER_ACTION_CONTACT_MANDATE_ADMIN = "CONTACT_MANDATE_ADMIN"
|
||||||
|
|
||||||
|
|
||||||
def _userActionForBillingModel(bm: BillingModelEnum) -> str:
|
def _defaultInsufficientBalanceUserAction() -> str:
|
||||||
if bm == BillingModelEnum.PREPAY_USER:
|
|
||||||
return BILLING_USER_ACTION_TOP_UP_SELF
|
|
||||||
return BILLING_USER_ACTION_CONTACT_MANDATE_ADMIN
|
return BILLING_USER_ACTION_CONTACT_MANDATE_ADMIN
|
||||||
|
|
||||||
|
|
||||||
def _buildInsufficientBalanceMessages(
|
def _buildInsufficientBalanceMessages(
|
||||||
bm: BillingModelEnum,
|
|
||||||
currentBalance: float,
|
currentBalance: float,
|
||||||
requiredAmount: float,
|
requiredAmount: float,
|
||||||
) -> tuple:
|
) -> tuple:
|
||||||
bal_s = f"{currentBalance:.2f}"
|
bal_s = f"{currentBalance:.2f}"
|
||||||
req_s = f"{requiredAmount:.2f}"
|
req_s = f"{requiredAmount:.2f}"
|
||||||
if bm == BillingModelEnum.PREPAY_USER:
|
msg_de = (
|
||||||
msg_de = (
|
f"Das Mandanten-Budget ist aufgebraucht (aktuell CHF {bal_s}, benötigt mindestens CHF {req_s}). "
|
||||||
f"Ihr persönliches Guthaben ist aufgebraucht (aktuell CHF {bal_s}, benötigt mindestens CHF {req_s}). "
|
"Bitte informieren Sie die Administratorin bzw. den Administrator Ihres Mandanten. "
|
||||||
"Bitte laden Sie unter „Billing“ Guthaben nach."
|
"Die in den Billing-Einstellungen hinterlegten Kontakte wurden per E-Mail informiert (falls konfiguriert)."
|
||||||
)
|
)
|
||||||
msg_en = (
|
msg_en = (
|
||||||
f"Your personal balance is exhausted (current CHF {bal_s}, at least CHF {req_s} required). "
|
f"The organization budget is exhausted (current CHF {bal_s}, at least CHF {req_s} required). "
|
||||||
"Please top up under Billing."
|
"Please contact your mandate administrator. Billing notification contacts were emailed if configured."
|
||||||
)
|
)
|
||||||
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
|
return msg_de, msg_en
|
||||||
|
|
||||||
|
|
||||||
class InsufficientBalanceException(Exception):
|
class InsufficientBalanceException(Exception):
|
||||||
"""Raised when there's insufficient balance for an operation.
|
"""Raised when there's insufficient balance for an operation.
|
||||||
|
|
||||||
Carries structured fields for API/SSE clients (userAction, billingModel, localized hints).
|
Carries structured fields for API/SSE clients (userAction, localized hints).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
|
|
@ -476,7 +454,6 @@ class InsufficientBalanceException(Exception):
|
||||||
requiredAmount: float,
|
requiredAmount: float,
|
||||||
message: Optional[str] = None,
|
message: Optional[str] = None,
|
||||||
*,
|
*,
|
||||||
billing_model: Optional[BillingModelEnum] = None,
|
|
||||||
mandate_id: str = "",
|
mandate_id: str = "",
|
||||||
user_action: Optional[str] = None,
|
user_action: Optional[str] = None,
|
||||||
message_de: Optional[str] = None,
|
message_de: Optional[str] = None,
|
||||||
|
|
@ -484,12 +461,8 @@ class InsufficientBalanceException(Exception):
|
||||||
):
|
):
|
||||||
self.currentBalance = float(currentBalance)
|
self.currentBalance = float(currentBalance)
|
||||||
self.requiredAmount = float(requiredAmount)
|
self.requiredAmount = float(requiredAmount)
|
||||||
self.billing_model = billing_model
|
|
||||||
self.mandate_id = mandate_id or ""
|
self.mandate_id = mandate_id or ""
|
||||||
if billing_model is not None:
|
self.user_action = user_action or _defaultInsufficientBalanceUserAction()
|
||||||
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:
|
if message_de is not None and message_en is not None:
|
||||||
self.message_de = message_de
|
self.message_de = message_de
|
||||||
|
|
@ -500,8 +473,7 @@ class InsufficientBalanceException(Exception):
|
||||||
self.message_de = message
|
self.message_de = message
|
||||||
self.message_en = message
|
self.message_en = message
|
||||||
else:
|
else:
|
||||||
bm = billing_model or BillingModelEnum.PREPAY_USER
|
md, me = _buildInsufficientBalanceMessages(self.currentBalance, self.requiredAmount)
|
||||||
md, me = _buildInsufficientBalanceMessages(bm, self.currentBalance, self.requiredAmount)
|
|
||||||
self.message_de = md
|
self.message_de = md
|
||||||
self.message_en = me
|
self.message_en = me
|
||||||
self.message = md
|
self.message = md
|
||||||
|
|
@ -514,14 +486,12 @@ class InsufficientBalanceException(Exception):
|
||||||
mandate_id: str,
|
mandate_id: str,
|
||||||
required_amount: float,
|
required_amount: float,
|
||||||
) -> "InsufficientBalanceException":
|
) -> "InsufficientBalanceException":
|
||||||
bm = check.billingModel or BillingModelEnum.PREPAY_MANDATE
|
|
||||||
bal = float(check.currentBalance or 0.0)
|
bal = float(check.currentBalance or 0.0)
|
||||||
msg_de, msg_en = _buildInsufficientBalanceMessages(bm, bal, required_amount)
|
msg_de, msg_en = _buildInsufficientBalanceMessages(bal, required_amount)
|
||||||
return cls(
|
return cls(
|
||||||
bal,
|
bal,
|
||||||
required_amount,
|
required_amount,
|
||||||
message=msg_de,
|
message=msg_de,
|
||||||
billing_model=bm,
|
|
||||||
mandate_id=mandate_id or "",
|
mandate_id=mandate_id or "",
|
||||||
message_de=msg_de,
|
message_de=msg_de,
|
||||||
message_en=msg_en,
|
message_en=msg_en,
|
||||||
|
|
@ -538,8 +508,6 @@ class InsufficientBalanceException(Exception):
|
||||||
"messageEn": self.message_en,
|
"messageEn": self.message_en,
|
||||||
"userAction": self.user_action,
|
"userAction": self.user_action,
|
||||||
}
|
}
|
||||||
if self.billing_model is not None:
|
|
||||||
out["billingModel"] = self.billing_model.value
|
|
||||||
if self.mandate_id:
|
if self.mandate_id:
|
||||||
out["mandateId"] = self.mandate_id
|
out["mandateId"] = self.mandate_id
|
||||||
if self.user_action == BILLING_USER_ACTION_TOP_UP_SELF:
|
if self.user_action == BILLING_USER_ACTION_TOP_UP_SELF:
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ def create_checkout_session(
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
mandate_id: Target mandate ID
|
mandate_id: Target mandate ID
|
||||||
user_id: Target user ID (for PREPAY_USER) or None (for mandate pool)
|
user_id: Target user ID for audit trail (optional)
|
||||||
amount_chf: Amount in CHF (must be in ALLOWED_AMOUNTS_CHF)
|
amount_chf: Amount in CHF (must be in ALLOWED_AMOUNTS_CHF)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|
|
||||||
|
|
@ -333,7 +333,8 @@ class ChatService:
|
||||||
token_status = "expired"
|
token_status = "expired"
|
||||||
else:
|
else:
|
||||||
# Check if this token was recently refreshed (within last 5 minutes)
|
# Check if this token was recently refreshed (within last 5 minutes)
|
||||||
time_since_creation = current_time - token.createdAt if hasattr(token, 'createdAt') else 0
|
createdTs = getattr(token, "sysCreatedAt", None)
|
||||||
|
time_since_creation = (current_time - createdTs) if createdTs else 0
|
||||||
if time_since_creation < 300: # 5 minutes
|
if time_since_creation < 300: # 5 minutes
|
||||||
token_status = "valid (refreshed)"
|
token_status = "valid (refreshed)"
|
||||||
else:
|
else:
|
||||||
|
|
@ -421,7 +422,7 @@ class ChatService:
|
||||||
"size": fileItem.fileSize,
|
"size": fileItem.fileSize,
|
||||||
"mimeType": fileItem.mimeType,
|
"mimeType": fileItem.mimeType,
|
||||||
"fileHash": fileItem.fileHash,
|
"fileHash": fileItem.fileHash,
|
||||||
"creationDate": fileItem.creationDate,
|
"creationDate": fileItem.sysCreatedAt,
|
||||||
"tags": getattr(fileItem, "tags", None),
|
"tags": getattr(fileItem, "tags", None),
|
||||||
"folderId": getattr(fileItem, "folderId", None),
|
"folderId": getattr(fileItem, "folderId", None),
|
||||||
"description": getattr(fileItem, "description", None),
|
"description": getattr(fileItem, "description", None),
|
||||||
|
|
@ -481,7 +482,7 @@ class ChatService:
|
||||||
"fileName": fileItem.fileName,
|
"fileName": fileItem.fileName,
|
||||||
"mimeType": fileItem.mimeType,
|
"mimeType": fileItem.mimeType,
|
||||||
"fileSize": fileItem.fileSize,
|
"fileSize": fileItem.fileSize,
|
||||||
"creationDate": fileItem.creationDate,
|
"creationDate": fileItem.sysCreatedAt,
|
||||||
"tags": getattr(fileItem, "tags", None),
|
"tags": getattr(fileItem, "tags", None),
|
||||||
"folderId": getattr(fileItem, "folderId", None),
|
"folderId": getattr(fileItem, "folderId", None),
|
||||||
"description": getattr(fileItem, "description", None),
|
"description": getattr(fileItem, "description", None),
|
||||||
|
|
@ -523,7 +524,7 @@ class ChatService:
|
||||||
mandateId=self._context.mandate_id or "",
|
mandateId=self._context.mandate_id or "",
|
||||||
userId=self.user.id if self.user else "",
|
userId=self.user.id if self.user else "",
|
||||||
)
|
)
|
||||||
return self.interfaceDbComponent.db.recordCreate(DataSource, ds)
|
return self.interfaceDbApp.db.recordCreate(DataSource, ds)
|
||||||
|
|
||||||
def listDataSources(self, featureInstanceId: str = None) -> List[Dict[str, Any]]:
|
def listDataSources(self, featureInstanceId: str = None) -> List[Dict[str, Any]]:
|
||||||
"""List data sources, optionally filtered by feature instance."""
|
"""List data sources, optionally filtered by feature instance."""
|
||||||
|
|
@ -531,19 +532,19 @@ class ChatService:
|
||||||
recordFilter = {}
|
recordFilter = {}
|
||||||
if featureInstanceId:
|
if featureInstanceId:
|
||||||
recordFilter["featureInstanceId"] = featureInstanceId
|
recordFilter["featureInstanceId"] = featureInstanceId
|
||||||
return self.interfaceDbComponent.db.getRecordset(DataSource, recordFilter=recordFilter)
|
return self.interfaceDbApp.db.getRecordset(DataSource, recordFilter=recordFilter)
|
||||||
|
|
||||||
def getDataSource(self, dataSourceId: str) -> Optional[Dict[str, Any]]:
|
def getDataSource(self, dataSourceId: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Get a single data source by ID."""
|
"""Get a single data source by ID."""
|
||||||
from modules.datamodels.datamodelDataSource import DataSource
|
from modules.datamodels.datamodelDataSource import DataSource
|
||||||
results = self.interfaceDbComponent.db.getRecordset(DataSource, recordFilter={"id": dataSourceId})
|
results = self.interfaceDbApp.db.getRecordset(DataSource, recordFilter={"id": dataSourceId})
|
||||||
return results[0] if results else None
|
return results[0] if results else None
|
||||||
|
|
||||||
def deleteDataSource(self, dataSourceId: str) -> bool:
|
def deleteDataSource(self, dataSourceId: str) -> bool:
|
||||||
"""Delete a data source."""
|
"""Delete a data source."""
|
||||||
from modules.datamodels.datamodelDataSource import DataSource
|
from modules.datamodels.datamodelDataSource import DataSource
|
||||||
try:
|
try:
|
||||||
self.interfaceDbComponent.db.recordDelete(DataSource, dataSourceId)
|
self.interfaceDbApp.db.recordDelete(DataSource, dataSourceId)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to delete DataSource {dataSourceId}: {e}")
|
logger.error(f"Failed to delete DataSource {dataSourceId}: {e}")
|
||||||
|
|
|
||||||
|
|
@ -346,7 +346,7 @@ class GenerationService:
|
||||||
"size": file_item.fileSize,
|
"size": file_item.fileSize,
|
||||||
"mimeType": file_item.mimeType,
|
"mimeType": file_item.mimeType,
|
||||||
"fileHash": getattr(file_item, 'fileHash', None),
|
"fileHash": getattr(file_item, 'fileHash', None),
|
||||||
"creationDate": getattr(file_item, 'creationDate', None)
|
"creationDate": getattr(file_item, 'sysCreatedAt', None)
|
||||||
}
|
}
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue