fixed udb issues

This commit is contained in:
ValueOn AG 2026-04-12 10:12:03 +02:00
parent c30c18fc71
commit 091a3672de
7 changed files with 309 additions and 114 deletions

View file

@ -14,7 +14,7 @@ import mimetypes
from typing import Dict, Any, List, Optional, Union
from modules.connectors.connectorDbPostgre import DatabaseConnector, _get_cached_connector
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC, getRecordsetPaginatedWithRBAC
from modules.security.rbac import RbacClass
from modules.datamodels.datamodelRbac import AccessRuleContext
from modules.datamodels.datamodelUam import AccessLevel
@ -958,11 +958,13 @@ class ComponentObjects:
def getAllFiles(self, pagination: Optional[PaginationParams] = None) -> Union[List[FileItem], PaginatedResult]:
"""
Returns files owned by the current user (user-scoped, not RBAC-based).
Returns files visible to the current user based on RBAC scope rules.
Mandate scoping:
- With mandateId: files of that mandate + files without mandate reference
- Without mandateId: only files without mandate reference
Visibility (via RBAC GROUP / scope-based filtering):
- Own files (sysCreatedBy = currentUser) always visible
- Files with scope='global' visible to everyone
- Files with scope='mandate' visible to members of that mandate
- Files with scope='featureInstance' visible to users with access to that instance
Args:
pagination: Optional pagination parameters. If None, returns all items.
@ -971,8 +973,6 @@ class ComponentObjects:
If pagination is None: List[FileItem]
If pagination is provided: PaginatedResult with items and metadata
"""
recordFilter = {"sysCreatedBy": self.userId}
def _convertFileItems(files):
fileItems = []
for file in files:
@ -999,48 +999,53 @@ class ComponentObjects:
continue
return fileItems
def _mandateFilter(files):
"""Apply mandate scoping:
- With mandate context: files of that mandate + mandateless files
- Without mandate context: ALL files (user already scoped by sysCreatedBy)"""
if self.mandateId:
return [f for f in files
if self._isMandatelessFile(f)
or f.get("mandateId") == self.mandateId]
return files
folderFilter = None
hasFolderFilter = False
if pagination and pagination.filters and "folderId" in pagination.filters:
folderFilter = pagination.filters.pop("folderId")
hasFolderFilter = True
def _applyFolderFilter(files):
if not hasFolderFilter:
return files
if folderFilter is None:
return [f for f in files if not (f.get("folderId") if isinstance(f, dict) else getattr(f, "folderId", None))]
return [f for f in files if (f.get("folderId") if isinstance(f, dict) else getattr(f, "folderId", None)) == folderFilter]
if pagination is None:
allFiles = self._getFilesByCurrentUser()
return _convertFileItems(_mandateFilter(allFiles))
# Mandate scoping cannot be expressed as a single recordFilter (OR logic),
# so we get the IDs of allowed files first, then use DB pagination on those.
allFiles = self._getFilesByCurrentUser()
allowedIds = [f.get("id") for f in _mandateFilter(allFiles) if f.get("id")]
allFiles = getRecordsetWithRBAC(
self.db, FileItem, self.currentUser,
mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId,
)
return _convertFileItems(_applyFolderFilter(allFiles))
if not allowedIds:
return PaginatedResult(items=[], totalItems=0, totalPages=0)
# DB-level pagination with ID whitelist + original user filter
recordFilter["id"] = allowedIds
result = self.db.getRecordsetPaginated(
FileItem,
result = getRecordsetPaginatedWithRBAC(
self.db, FileItem, self.currentUser,
pagination=pagination,
recordFilter=recordFilter,
mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId,
)
items = _convertFileItems(result["items"])
if isinstance(result, PaginatedResult):
filtered = _applyFolderFilter(result.items)
return PaginatedResult(
items=_convertFileItems(filtered),
totalItems=len(filtered),
totalPages=max(1, -(-len(filtered) // (pagination.pageSize or 20))),
)
return PaginatedResult(
items=items,
totalItems=result["totalItems"],
totalPages=result["totalPages"]
)
raw = result if isinstance(result, list) else []
return _convertFileItems(_applyFolderFilter(raw))
def getFile(self, fileId: str) -> Optional[FileItem]:
"""Returns a file by ID if it belongs to the current user (user-scoped)."""
# Files are always user-scoped: filter by sysCreatedBy (bypasses RBAC SysAdmin override)
filteredFiles = self._getFilesByCurrentUser(recordFilter={"id": fileId})
"""Returns a file by ID if the current user has RBAC access (scope-based)."""
filteredFiles = getRecordsetWithRBAC(
self.db, FileItem, self.currentUser,
recordFilter={"id": fileId},
mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId,
)
if not filteredFiles:
return None
@ -1049,18 +1054,14 @@ class ComponentObjects:
try:
sysCreatedAt = file.get("sysCreatedAt")
if sysCreatedAt is None or not isinstance(sysCreatedAt, (int, float)) or sysCreatedAt <= 0:
sysCreatedAt = getUtcTimestamp()
file["sysCreatedAt"] = getUtcTimestamp()
return FileItem(
id=file.get("id"),
mandateId=file.get("mandateId"),
featureInstanceId=file.get("featureInstanceId", ""),
fileName=file.get("fileName"),
mimeType=file.get("mimeType"),
fileHash=file.get("fileHash"),
fileSize=file.get("fileSize"),
sysCreatedAt=sysCreatedAt,
)
if file.get("scope") is None:
file["scope"] = "personal"
if file.get("neutralize") is None:
file["neutralize"] = False
return FileItem(**file)
except Exception as e:
logger.error(f"Error converting file record: {str(e)}")
return None
@ -1139,7 +1140,8 @@ class ComponentObjects:
fileName=uniqueName,
mimeType=mimeType,
fileSize=fileSize,
fileHash=fileHash
fileHash=fileHash,
folderId="",
)
# Store in database
@ -1147,15 +1149,36 @@ class ComponentObjects:
return fileItem
def _isFileOwner(self, file) -> bool:
"""Check if the current user owns the file."""
createdBy = getattr(file, "sysCreatedBy", None) or (file.get("sysCreatedBy") if isinstance(file, dict) else None)
return createdBy == self.userId
def _requireFileWriteAccess(self, file, fileId: str, operation: str = "update"):
"""Raise PermissionError if the user cannot mutate this file.
Owners always can. Non-owners need RBAC ALL level."""
if self._isFileOwner(file):
return
from modules.interfaces.interfaceRbac import buildDataObjectKey
from modules.datamodels.datamodelRbac import AccessRuleContext
objectKey = buildDataObjectKey("FileItem")
permissions = self.rbac.getUserPermissions(
self.currentUser, AccessRuleContext.DATA, objectKey,
mandateId=self.mandateId, featureInstanceId=self.featureInstanceId,
)
level = getattr(permissions, operation, None)
if level != AccessLevel.ALL:
raise PermissionError(
f"No permission to {operation} file {fileId} (not owner, access level: {level})"
)
def updateFile(self, fileId: str, updateData: Dict[str, Any]) -> Dict[str, Any]:
"""Updates file metadata if user has access."""
# Check if the file exists and user has access
"""Updates file metadata if user has access and is owner (or has ALL)."""
file = self.getFile(fileId)
if not file:
raise FileNotFoundError(f"File with ID {fileId} not found")
if not self.checkRbacPermission(FileItem, "update", fileId):
raise PermissionError(f"No permission to update file {fileId}")
self._requireFileWriteAccess(file, fileId, "update")
# If fileName is being updated, ensure it's unique
if "fileName" in updateData:
@ -1168,16 +1191,14 @@ class ComponentObjects:
return success
def deleteFile(self, fileId: str) -> bool:
"""Deletes a file if user has access."""
"""Deletes a file if user is owner (or has ALL access)."""
try:
# Check if the file exists and user has access
file = self.getFile(fileId)
if not file:
raise FileNotFoundError(f"File with ID {fileId} not found")
if not self.checkRbacPermission(FileItem, "update", fileId):
raise PermissionError(f"No permission to delete file {fileId}")
self._requireFileWriteAccess(file, fileId, "delete")
# Check for other references to this file (by hash) - user-scoped check
fileHash = file.fileHash
@ -1211,7 +1232,8 @@ class ComponentObjects:
raise FileDeletionError(f"Error deleting file: {str(e)}")
def deleteFilesBatch(self, fileIds: List[str]) -> Dict[str, Any]:
"""Delete multiple files in a single SQL batch call."""
"""Delete multiple files in a single SQL batch call.
Owner can always delete; non-owners need RBAC ALL level."""
uniqueIds = [str(fid) for fid in dict.fromkeys(fileIds or []) if fid]
if not uniqueIds:
return {"deletedFiles": 0}
@ -1220,20 +1242,21 @@ class ComponentObjects:
self.db._ensure_connection()
with self.db.connection.cursor() as cursor:
cursor.execute(
'SELECT "id" FROM "FileItem" WHERE "id" = ANY(%s) AND "sysCreatedBy" = %s',
(uniqueIds, self.userId or ""),
'SELECT "id", "sysCreatedBy" FROM "FileItem" WHERE "id" = ANY(%s)',
(uniqueIds,),
)
accessibleIds = [row["id"] for row in cursor.fetchall()]
rows = cursor.fetchall()
foundIds = {row["id"] for row in rows}
missing = sorted(set(uniqueIds) - foundIds)
if missing:
raise FileNotFoundError(f"Files not found: {missing}")
if len(accessibleIds) != len(uniqueIds):
missingIds = sorted(set(uniqueIds) - set(accessibleIds))
raise FileNotFoundError(f"Files not found or not accessible: {missingIds}")
for row in rows:
self._requireFileWriteAccess(row, row["id"], "delete")
accessibleIds = [row["id"] for row in rows]
cursor.execute('DELETE FROM "FileData" WHERE "id" = ANY(%s)', (accessibleIds,))
cursor.execute(
'DELETE FROM "FileItem" WHERE "id" = ANY(%s) AND "sysCreatedBy" = %s',
(accessibleIds, self.userId or ""),
)
cursor.execute('DELETE FROM "FileItem" WHERE "id" = ANY(%s)', (accessibleIds,))
deletedFiles = cursor.rowcount
self.db.connection.commit()
@ -1274,6 +1297,40 @@ class ComponentObjects:
currentId = folders[0].get("parentId")
return False
def _ensureFeatureInstanceFolder(self, featureInstanceId: str, mandateId: str = "") -> Optional[str]:
"""Return the folder ID for a feature instance, creating it on first use.
The folder is named after the feature instance label."""
existing = self.db.getRecordset(
FileFolder,
recordFilter={
"featureInstanceId": featureInstanceId,
"sysCreatedBy": self.userId or "",
},
)
if existing:
return existing[0].get("id")
# Resolve the instance label for the folder name
folderName = featureInstanceId[:8]
try:
from modules.datamodels.datamodelFeatures import FeatureInstance
from modules.security.rootAccess import getRootDbAppConnector
dbApp = getRootDbAppConnector()
instances = dbApp.getRecordset(FeatureInstance, recordFilter={"id": featureInstanceId})
if instances:
folderName = instances[0].get("label") or folderName
except Exception as e:
logger.warning(f"Could not resolve feature instance label: {e}")
folder = FileFolder(
name=folderName,
parentId=None,
mandateId=mandateId,
featureInstanceId=featureInstanceId,
)
created = self.db.recordCreate(FileFolder, folder)
return created.get("id") if isinstance(created, dict) else getattr(created, "id", None)
def getFolder(self, folderId: str) -> Optional[Dict[str, Any]]:
"""Returns a folder by ID if it belongs to the current user."""
folders = self.db.getRecordset(FileFolder, recordFilter={"id": folderId, "sysCreatedBy": self.userId or ""})
@ -1281,7 +1338,8 @@ class ComponentObjects:
def listFolders(self, parentId: Optional[str] = None) -> List[Dict[str, Any]]:
"""List folders for current user, optionally filtered by parentId.
Each folder is enriched with ``fileCount`` (number of direct files)."""
Each folder is enriched with ``fileCount`` (number of direct files
visible to this user via RBAC scope rules)."""
recordFilter = {"sysCreatedBy": self.userId or ""}
if parentId is not None:
recordFilter["parentId"] = parentId
@ -1293,23 +1351,32 @@ class ComponentObjects:
folderIds = [f["id"] for f in folders if f.get("id")]
fileCounts: Dict[str, int] = {}
try:
mandateClause = ""
mandateValues: list = []
if self.mandateId:
mandateClause = (
' AND ("mandateId" = %s OR "mandateId" IS NULL OR "mandateId" = \'\')'
)
mandateValues = [self.mandateId]
# Count files per folder that the user can see (RBAC scope-aware).
# Own files are always counted; shared files (global/mandate/featureInstance)
# that happen to be in one of the user's folders are also counted.
from modules.interfaces.interfaceRbac import _buildFilesScopeWhereClause
from modules.datamodels.datamodelUam import User as UserModel
scopeClause = _buildFilesScopeWhereClause(
self.currentUser, "FileItem", self.db,
self.mandateId, self.featureInstanceId,
[], [],
)
self.db._ensure_connection()
with self.db.connection.cursor() as cursor:
cursor.execute(
baseQuery = (
'SELECT "folderId", COUNT(*) AS cnt '
'FROM "FileItem" '
'WHERE "sysCreatedBy" = %s AND "folderId" = ANY(%s)'
+ mandateClause +
' GROUP BY "folderId"',
[self.userId or "", folderIds] + mandateValues,
'WHERE "folderId" = ANY(%s)'
)
queryValues: list = [folderIds]
if scopeClause:
baseQuery += ' AND (' + scopeClause["condition"] + ')'
queryValues.extend(scopeClause["values"])
baseQuery += ' GROUP BY "folderId"'
cursor.execute(baseQuery, queryValues)
for row in cursor.fetchall():
fileCounts[row["folderId"]] = row["cnt"]
except Exception as e:
@ -1350,7 +1417,8 @@ class ComponentObjects:
return self.db.recordModify(FileFolder, folderId, {"parentId": targetParentId})
def moveFilesBatch(self, fileIds: List[str], targetFolderId: Optional[str] = None) -> Dict[str, Any]:
"""Move multiple files with one SQL update."""
"""Move multiple files with one SQL update.
Owner can always move; non-owners need RBAC ALL level."""
uniqueIds = [str(fid) for fid in dict.fromkeys(fileIds or []) if fid]
if not uniqueIds:
return {"movedFiles": 0}
@ -1364,18 +1432,23 @@ class ComponentObjects:
self.db._ensure_connection()
with self.db.connection.cursor() as cursor:
cursor.execute(
'SELECT "id" FROM "FileItem" WHERE "id" = ANY(%s) AND "sysCreatedBy" = %s',
(uniqueIds, self.userId or ""),
'SELECT "id", "sysCreatedBy" FROM "FileItem" WHERE "id" = ANY(%s)',
(uniqueIds,),
)
accessibleIds = [row["id"] for row in cursor.fetchall()]
if len(accessibleIds) != len(uniqueIds):
missingIds = sorted(set(uniqueIds) - set(accessibleIds))
raise FileNotFoundError(f"Files not found or not accessible: {missingIds}")
rows = cursor.fetchall()
foundIds = {row["id"] for row in rows}
missing = sorted(set(uniqueIds) - foundIds)
if missing:
raise FileNotFoundError(f"Files not found: {missing}")
for row in rows:
self._requireFileWriteAccess(row, row["id"], "update")
accessibleIds = [row["id"] for row in rows]
cursor.execute(
'UPDATE "FileItem" SET "folderId" = %s, "sysModifiedAt" = %s, "sysModifiedBy" = %s '
'WHERE "id" = ANY(%s) AND "sysCreatedBy" = %s',
(targetFolderId, getUtcTimestamp(), self.userId or "", accessibleIds, self.userId or ""),
'WHERE "id" = ANY(%s)',
(targetFolderId, getUtcTimestamp(), self.userId or "", accessibleIds),
)
movedFiles = cursor.rowcount

View file

@ -17,7 +17,8 @@ Data Namespace Structure:
GROUP-Berechtigung:
- data.uam.*: GROUP filtert nach Mandant (via UserMandate)
- data.chat.*, data.files.*, data.automation.*: GROUP = MY (benutzer-eigen); bei gesetztem featureInstanceId zusätzlich sysCreatedBy
- data.chat.*, data.automation.*: GROUP = MY (benutzer-eigen); bei gesetztem featureInstanceId zusätzlich sysCreatedBy
- data.files.*: GROUP = eigene Files + scope-basierte Sichtbarkeit (global, mandate, featureInstance)
- data.feature.*: GROUP filtert nach mandateId/featureInstanceId
"""
@ -93,7 +94,8 @@ TABLE_NAMESPACE = {
}
# Namespaces ohne Mandantenkontext - GROUP wird auf MY gemappt
USER_OWNED_NAMESPACES = {"chat", "chatbot", "files", "automation", "knowledge", "datasource"}
# NOTE: "files" is NOT in this set files use scope-based visibility for GROUP
USER_OWNED_NAMESPACES = {"chat", "chatbot", "automation", "knowledge", "datasource"}
def buildDataObjectKey(tableName: str, featureCode: Optional[str] = None) -> str:
@ -612,6 +614,82 @@ def getDistinctColumnValuesWithRBAC(
return []
def _buildFilesScopeWhereClause(
currentUser: User,
table: str,
connector,
mandateId: Optional[str],
featureInstanceId: Optional[str],
baseConditions: List[str],
baseValues: List,
) -> Optional[Dict[str, Any]]:
"""Build WHERE clause for files namespace with scope-based visibility.
Two modes depending on request context:
WITHOUT instance/mandate context (Dateien-Seite):
Only own files: sysCreatedBy = currentUser
WITH instance context (Instanz-Seiten):
- sysCreatedBy = me AND featureInstanceId = X (own personal files of this instance)
- scope = 'featureInstance' AND featureInstanceId = X
- scope = 'mandate' AND mandateId = M (M = mandate of the instance)
- scope = 'global'
"""
conditions = list(baseConditions)
values = list(baseValues)
# ── No context: Dateien-Seite → only own files ────────────────────────
if not featureInstanceId and not mandateId:
conditions.append('"sysCreatedBy" = %s')
values.append(currentUser.id)
if conditions:
return {"condition": " AND ".join(conditions), "values": values}
return None
# ── With context: Instanz-/Mandanten-Seite → scope-based visibility ──
effectiveMandateId = mandateId
if featureInstanceId and not effectiveMandateId:
try:
from modules.datamodels.datamodelFeatures import FeatureInstance
dbApp = getRootDbAppConnector()
instances = dbApp.getRecordset(
FeatureInstance, recordFilter={"id": featureInstanceId},
)
if instances:
effectiveMandateId = instances[0].get("mandateId") or ""
except Exception as e:
logger.warning(f"_buildFilesScopeWhereClause: could not resolve mandate for instance {featureInstanceId}: {e}")
scopeParts: List[str] = []
scopeValues: List = []
if featureInstanceId:
# 1) Own personal files of this specific instance
scopeParts.append('("sysCreatedBy" = %s AND "featureInstanceId" = %s)')
scopeValues.extend([currentUser.id, featureInstanceId])
# 2) scope=featureInstance files shared with this instance
scopeParts.append('("scope" = \'featureInstance\' AND "featureInstanceId" = %s)')
scopeValues.append(featureInstanceId)
# 3) scope=mandate files of the effective mandate
if effectiveMandateId:
scopeParts.append('("scope" = \'mandate\' AND "mandateId" = %s)')
scopeValues.append(effectiveMandateId)
# 4) scope=global files
scopeParts.append('"scope" = \'global\'')
if scopeParts:
conditions.append("(" + " OR ".join(scopeParts) + ")")
values.extend(scopeValues)
if conditions:
return {"condition": " AND ".join(conditions), "values": values}
return None
def buildRbacWhereClause(
permissions: UserPermissions,
currentUser: User,
@ -648,12 +726,15 @@ def buildRbacWhereClause(
return {"condition": "1 = 0", "values": []}
# CRITICAL: featureInstanceId filter is ALWAYS required when provided
# This ensures data isolation between feature instances regardless of access level
# This ensures data isolation between feature instances regardless of access level.
# EXCEPTION: files namespace handles featureInstanceId inside its own scope logic
# because files with scope=global or scope=mandate must remain visible even when
# they belong to a different (or no) featureInstanceId.
baseConditions = []
baseValues = []
if featureInstanceId:
# Strict filter: only records for this exact feature instance
namespace = TABLE_NAMESPACE.get(table, "system")
if featureInstanceId and namespace != "files":
baseConditions.append('"featureInstanceId" = %s')
baseValues.append(featureInstanceId)
@ -703,7 +784,19 @@ def buildRbacWhereClause(
# Determine namespace for this table
namespace = TABLE_NAMESPACE.get(table, "system")
# For user-owned namespaces (chat, files, automation):
# ── Files namespace: scope-based visibility ──────────────────────
# GROUP for files = own files + shared files based on scope field:
# - scope='global' → visible to everyone
# - scope='mandate' → visible to users in that mandate
# - scope='featureInstance' → visible to users with access to that instance
# - scope='personal' → only visible to owner (sysCreatedBy)
if namespace == "files":
return _buildFilesScopeWhereClause(
currentUser, table, connector, mandateId, featureInstanceId,
baseConditions, baseValues,
)
# For user-owned namespaces (chat, automation):
# GROUP has no meaning - these tables have no mandate context
# But still apply featureInstanceId filter if provided
if namespace in USER_OWNED_NAMESPACES:

View file

@ -866,9 +866,13 @@ def update_file(
) -> FileItem:
"""Update file info"""
try:
_EDITABLE_FIELDS = {"fileName", "scope", "tags", "description", "folderId", "neutralize"}
safeData = {k: v for k, v in file_info.items() if k in _EDITABLE_FIELDS}
if not safeData:
raise HTTPException(status_code=400, detail=routeApiMsg("No editable fields provided"))
managementInterface = interfaceDbManagement.getInterface(currentUser)
# Get the file from the database
file = managementInterface.getFile(fileId)
if not file:
raise HTTPException(
@ -876,21 +880,19 @@ def update_file(
detail=f"File with ID {fileId} not found"
)
if file_info.get("scope") == "global" and not _hasSysAdminRole(str(currentUser.id)):
if safeData.get("scope") == "global" and not _hasSysAdminRole(str(currentUser.id)):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=routeApiMsg("Only sysadmins can set global scope"),
)
# Check if user has access to the file using RBAC
if not managementInterface.checkRbacPermission(FileItem, "update", fileId):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=routeApiMsg("Not authorized to update this file")
)
# Update the file
result = managementInterface.updateFile(fileId, file_info)
result = managementInterface.updateFile(fileId, safeData)
if not result:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,

View file

@ -86,12 +86,10 @@ class RbacClass:
# NOTE: sysadmin ROLE users get full access via AccessRules (DATA: ALL)
# This flag bypass is kept as fallback for true system-level operations
if hasattr(user, 'isSysAdmin') and user.isSysAdmin:
# User-owned namespaces: SysAdmin gets MY access only (own data).
# Every user -- including SysAdmin -- only has CRUD for their own
# chat workflows and files. Automation is excluded because it's
# managed by admins and the system event user needs ALL access.
_USER_OWNED_PREFIXES = ("data.chat.", "data.files.")
if item and any(item.startswith(p) for p in _USER_OWNED_PREFIXES):
# Chat namespace: SysAdmin gets MY access only (own data).
# Files namespace: SysAdmin gets ALL (can manage all files in system).
_CHAT_PREFIXES = ("data.chat.",)
if item and any(item.startswith(p) for p in _CHAT_PREFIXES):
return UserPermissions(
view=True,
read=AccessLevel.MY,

View file

@ -5,6 +5,8 @@
import logging
from typing import Optional
logger = logging.getLogger(__name__)
_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")
@ -43,6 +45,21 @@ def _looksLikeBinary(data: bytes, sampleSize: int = 1024) -> bool:
return nonPrintable / len(sample) > 0.10
def _getOrCreateInstanceFolder(chatService, featureInstanceId: str, mandateId: str = "") -> Optional[str]:
"""Return the folder ID for a feature instance, creating it on first use.
Delegates to interfaceDbManagement._ensureFeatureInstanceFolder.
AI tools call this when saving a file without an explicit folderId
so that instance-produced files land in a named folder automatically.
"""
try:
dbMgmt = chatService.interfaceDbComponent
return dbMgmt._ensureFeatureInstanceFolder(featureInstanceId, mandateId)
except Exception as e:
logger.warning(f"Could not get/create instance folder for {featureInstanceId}: {e}")
return None
def _getOrCreateTempFolder(chatService) -> Optional[str]:
"""Return the ID of the root-level 'Temp' folder, creating it if it doesn't exist."""
try:

View file

@ -9,6 +9,7 @@ from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolResul
from modules.serviceCenter.services.serviceAgent.toolRegistry import ToolRegistry
from modules.serviceCenter.services.serviceAgent.coreTools._helpers import (
_getOrCreateInstanceFolder,
_getOrCreateTempFolder,
_looksLikeBinary,
_resolveFileScope,
@ -421,6 +422,10 @@ def _registerWorkspaceTools(registry: ToolRegistry, services):
dbMgmt.updateFile(fileItem.id, {"featureInstanceId": fiId})
if args.get("folderId"):
dbMgmt.updateFile(fileItem.id, {"folderId": args["folderId"]})
elif fiId:
instanceFolderId = _getOrCreateInstanceFolder(chatService, fiId, context.get("mandateId", ""))
if instanceFolderId:
dbMgmt.updateFile(fileItem.id, {"folderId": instanceFolderId})
if args.get("tags"):
dbMgmt.updateFile(fileItem.id, {"tags": args["tags"]})
return ToolResult(

View file

@ -63,15 +63,22 @@ def getModelLabels(modelName: str) -> Dict[str, str]:
def _resolveOptionLabels(options):
"""Resolve frontend_options label values via resolveText()."""
"""Resolve frontend_options label values via resolveText().
CRITICAL: deep-copy so the shared json_schema_extra dicts are never mutated.
Without the copy, each request would re-translate the already-translated
label, wrapping it in another layer of ``[]`` brackets.
"""
if not isinstance(options, list):
return options
import copy
from modules.shared.i18nRegistry import resolveText
for opt in options:
resolved = copy.deepcopy(options)
for opt in resolved:
if not isinstance(opt, dict) or "label" not in opt:
continue
opt["label"] = resolveText(opt["label"])
return options
return resolved
def _mergedAttributeLabels(modelClass: Type[BaseModel]) -> Dict[str, str]: