fixing round 1

This commit is contained in:
ValueOn AG 2026-03-28 16:59:01 +01:00
parent b33444e891
commit efe540b4f9
24 changed files with 844 additions and 226 deletions

3
app.py
View file

@ -545,6 +545,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)

View file

@ -168,6 +168,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):

View file

@ -25,6 +25,21 @@ class FeatureDataSource(BaseModel):
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") 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(

View file

@ -42,6 +42,10 @@ class FileContentIndex(BaseModel):
default=None, default=None,
description="Neutralization status: completed, failed, skipped, None = not required", description="Neutralization status: completed, failed, skipped, None = not required",
) )
isNeutralized: bool = Field(
default=False,
description="True if content was neutralized before indexing",
)
registerModelLabels( registerModelLabels(
@ -64,6 +68,7 @@ registerModelLabels(
"status": {"en": "Status", "fr": "Statut"}, "status": {"en": "Status", "fr": "Statut"},
"scope": {"en": "Scope", "de": "Sichtbarkeit"}, "scope": {"en": "Scope", "de": "Sichtbarkeit"},
"neutralizationStatus": {"en": "Neutralization Status", "de": "Neutralisierungsstatus"}, "neutralizationStatus": {"en": "Neutralization Status", "de": "Neutralisierungsstatus"},
"isNeutralized": {"en": "Is Neutralized", "de": "Neutralisiert"},
}, },
) )

View file

@ -103,6 +103,11 @@ class Mandate(BaseModel):
{"value": "company", "label": {"en": "Company", "de": "Unternehmen"}}, {"value": "company", "label": {"en": "Company", "de": "Unternehmen"}},
]} ]}
) )
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
@ -135,6 +140,7 @@ registerModelLabels(
"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"},
"mandateType": {"en": "Mandate Type", "de": "Mandantentyp", "fr": "Type de mandat"}, "mandateType": {"en": "Mandate Type", "de": "Mandantentyp", "fr": "Type de mandat"},
"deletedAt": {"en": "Deleted at", "de": "Gelöscht am", "fr": "Supprimé le"},
}, },
) )

View file

@ -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

View file

@ -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.
""" """

View file

@ -893,6 +893,23 @@ async def listWorkspaceWorkflows(
_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):
@ -904,9 +921,15 @@ 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)
items.append(item) items.append(item)
return JSONResponse({"workflows": items}) return JSONResponse({"workflows": items})

View file

@ -133,6 +133,14 @@ 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:
""" """

View file

@ -800,48 +800,6 @@ class AppObjects:
logger.error(f"Error updating user: {str(e)}") logger.error(f"Error updating user: {str(e)}")
raise ValueError(f"Failed to update user: {str(e)}") raise ValueError(f"Failed to update user: {str(e)}")
def _assignUserToRootMandate(self, userId: str) -> None:
"""
Assign a new user to the root mandate with the mandate-instance 'user' role.
This ensures every user has a base membership in the system mandate.
Uses the mandate-instance role (mandateId=rootMandateId), not the global template.
Feature instance access is NOT granted here - it is managed separately
via invitations or admin assignment.
Args:
userId: User ID to assign
"""
try:
from modules.datamodels.datamodelRbac import Role
rootMandateId = self._getRootMandateId()
if not rootMandateId:
logger.warning("No root mandate found, skipping root mandate assignment")
return
# Check if user already has a mandate membership
existing = self.getUserMandate(userId, rootMandateId)
if existing:
logger.debug(f"User {userId} already assigned to root mandate")
return
# Mandate-instance 'user' role (bound to this mandate, not a global template)
mandateUserRoles = self.db.getRecordset(
Role,
recordFilter={"roleLabel": "user", "mandateId": rootMandateId, "featureInstanceId": None}
)
userRoleId = mandateUserRoles[0].get("id") if mandateUserRoles else None
roleIds = [userRoleId] if userRoleId else []
self.createUserMandate(userId, rootMandateId, roleIds)
logger.info(f"Assigned user {userId} to root mandate with user role")
except Exception as e:
# Log but don't fail user creation
logger.error(f"Error assigning user {userId} to root mandate: {e}")
def disableUser(self, userId: str) -> User: def disableUser(self, userId: str) -> User:
"""Disables a user if current user has permission.""" """Disables a user if current user has permission."""
return self.updateUser(userId, {"enabled": False}) return self.updateUser(userId, {"enabled": False})
@ -1493,11 +1451,10 @@ class AppObjects:
adminRoleId = r.get("id") adminRoleId = r.get("id")
break break
userMandate = UserMandate(userId=userId, mandateId=mandateId, enabled=True) if not adminRoleId:
createdUm = self.db.recordCreate(UserMandate, userMandate.model_dump()) raise ValueError(f"No admin role found for mandate {mandateId} — cannot assign user without role")
if adminRoleId and createdUm:
umRole = UserMandateRole(userMandateId=createdUm["id"], roleId=adminRoleId) self.createUserMandate(userId, mandateId, roleIds=[adminRoleId])
self.db.recordCreate(UserMandateRole, umRole.model_dump())
subscription = MandateSubscription( subscription = MandateSubscription(
mandateId=mandateId, mandateId=mandateId,
@ -1533,14 +1490,16 @@ class AppObjects:
instanceRoles = self.db.getRecordset(Role, recordFilter={"featureInstanceId": instanceId}) instanceRoles = self.db.getRecordset(Role, recordFilter={"featureInstanceId": instanceId})
adminInstRoleId = None adminInstRoleId = None
for ir in instanceRoles: for ir in instanceRoles:
if "admin" in (ir.get("roleLabel") or "").lower(): roleLabel = (ir.get("roleLabel") or "").lower()
if roleLabel.endswith("-admin"):
adminInstRoleId = ir.get("id") adminInstRoleId = ir.get("id")
break break
fa = FeatureAccess(userId=userId, featureInstanceId=instanceId, enabled=True) if not adminInstRoleId:
createdFa = self.db.recordCreate(FeatureAccess, fa.model_dump()) raise ValueError(
if adminInstRoleId and createdFa: f"No feature-specific admin role (e.g. {featureCode}-admin) for instance {instanceId}. "
far = FeatureAccessRole(featureAccessId=createdFa["id"], roleId=adminInstRoleId) f"Template roles not synced for feature '{featureCode}'."
self.db.recordCreate(FeatureAccessRole, far.model_dump()) )
self.createFeatureAccess(userId, instanceId, roleIds=[adminInstRoleId])
except Exception as e: except Exception as e:
logger.error(f"Error auto-creating instance for '{featureName}': {e}") logger.error(f"Error auto-creating instance for '{featureName}': {e}")
@ -1669,15 +1628,72 @@ class AppObjects:
raise PermissionError(f"No permission to delete mandate {mandateId}") raise PermissionError(f"No permission to delete mandate {mandateId}")
if not force: if not force:
self.db.recordModify(Mandate, mandateId, {"enabled": False}) from modules.shared.timeUtils import getUtcTimestamp
logger.info(f"Soft-deleted mandate {mandateId}") self.db.recordModify(Mandate, mandateId, {"enabled": False, "deletedAt": getUtcTimestamp()})
logger.info(f"Soft-deleted mandate {mandateId} (30-day retention)")
return True return True
# Hard delete with cascade # Hard delete with cascade
from modules.datamodels.datamodelSubscription import MandateSubscription from modules.datamodels.datamodelSubscription import MandateSubscription
from modules.datamodels.datamodelChat import ChatWorkflow, ChatMessage, ChatLog
from modules.datamodels.datamodelFiles import FileItem
from modules.datamodels.datamodelDataSource import DataSource
from modules.datamodels.datamodelKnowledge import FileContentIndex
from modules.features.neutralization.datamodelFeatureNeutralizer import DataNeutralizerAttributes
instances = self.db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId})
# 0. Delete instance-scoped data for each FeatureInstance
for inst in instances:
instId = inst.get("id")
if not instId:
continue
# 0a. FileContentIndex (knowledge/RAG)
fciRecords = self.db.getRecordset(FileContentIndex, recordFilter={"featureInstanceId": instId})
for rec in fciRecords:
self.db.recordDelete(FileContentIndex, rec.get("id"))
if fciRecords:
logger.info(f"Cascade: deleted {len(fciRecords)} FileContentIndex records for instance {instId}")
# 0b. DataNeutralizerAttributes
dnaRecords = self.db.getRecordset(DataNeutralizerAttributes, recordFilter={"featureInstanceId": instId})
for rec in dnaRecords:
self.db.recordDelete(DataNeutralizerAttributes, rec.get("id"))
if dnaRecords:
logger.info(f"Cascade: deleted {len(dnaRecords)} DataNeutralizerAttributes for instance {instId}")
# 0c. DataSource
dsRecords = self.db.getRecordset(DataSource, recordFilter={"featureInstanceId": instId})
for rec in dsRecords:
self.db.recordDelete(DataSource, rec.get("id"))
if dsRecords:
logger.info(f"Cascade: deleted {len(dsRecords)} DataSource records for instance {instId}")
# 0d. FileItem
fileRecords = self.db.getRecordset(FileItem, recordFilter={"featureInstanceId": instId})
for rec in fileRecords:
self.db.recordDelete(FileItem, rec.get("id"))
if fileRecords:
logger.info(f"Cascade: deleted {len(fileRecords)} FileItem records for instance {instId}")
# 0e. ChatWorkflow + ChatMessage + ChatLog
workflows = self.db.getRecordset(ChatWorkflow, recordFilter={"featureInstanceId": instId})
for wf in workflows:
wfId = wf.get("id")
if not wfId:
continue
msgs = self.db.getRecordset(ChatMessage, recordFilter={"workflowId": wfId})
for msg in msgs:
self.db.recordDelete(ChatMessage, msg.get("id"))
logs = self.db.getRecordset(ChatLog, recordFilter={"workflowId": wfId})
for log in logs:
self.db.recordDelete(ChatLog, log.get("id"))
self.db.recordDelete(ChatWorkflow, wfId)
if workflows:
logger.info(f"Cascade: deleted {len(workflows)} ChatWorkflows (with messages/logs) for instance {instId}")
# 1. Delete FeatureAccess + FeatureAccessRole for all instances in this mandate # 1. Delete FeatureAccess + FeatureAccessRole for all instances in this mandate
instances = self.db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId})
for inst in instances: for inst in instances:
instId = inst.get("id") instId = inst.get("id")
accesses = self.db.getRecordset(FeatureAccess, recordFilter={"featureInstanceId": instId}) accesses = self.db.getRecordset(FeatureAccess, recordFilter={"featureInstanceId": instId})
@ -1692,10 +1708,20 @@ class AppObjects:
self.db.recordDelete(UserMandate, um.get("id")) self.db.recordDelete(UserMandate, um.get("id"))
logger.info(f"Cascade: deleted {len(memberships)} UserMandates for mandate {mandateId}") logger.info(f"Cascade: deleted {len(memberships)} UserMandates for mandate {mandateId}")
# 3. Delete MandateSubscriptions # 3. Cancel Stripe subscriptions + delete MandateSubscription records
subs = self.db.getRecordset(MandateSubscription, recordFilter={"mandateId": mandateId}) subs = self.db.getRecordset(MandateSubscription, recordFilter={"mandateId": mandateId})
for sub in subs: for sub in subs:
self.db.recordDelete(MandateSubscription, sub.get("id")) subId = sub.get("id")
stripeSubId = sub.get("stripeSubscriptionId")
if stripeSubId:
try:
from modules.shared.stripeClient import getStripeClient
stripe = getStripeClient()
stripe.Subscription.cancel(stripeSubId)
logger.info(f"Cancelled Stripe subscription {stripeSubId} for mandate {mandateId}")
except Exception as e:
logger.warning(f"Failed to cancel Stripe sub {stripeSubId}: {e}")
self.db.recordDelete(MandateSubscription, subId)
logger.info(f"Cascade: deleted {len(subs)} subscriptions for mandate {mandateId}") logger.info(f"Cascade: deleted {len(subs)} subscriptions for mandate {mandateId}")
# 4. Delete mandate-level Roles # 4. Delete mandate-level Roles
@ -1717,6 +1743,35 @@ class AppObjects:
logger.error(f"Error deleting mandate: {str(e)}") logger.error(f"Error deleting mandate: {str(e)}")
raise ValueError(f"Failed to delete mandate: {str(e)}") raise ValueError(f"Failed to delete mandate: {str(e)}")
def restoreMandate(self, mandateId: str) -> bool:
"""Restore a soft-deleted mandate (undo soft-delete within the 30-day retention window)."""
mandate = self.getMandate(mandateId)
if not mandate:
return False
self.db.recordModify(Mandate, mandateId, {"enabled": True, "deletedAt": None})
logger.info(f"Restored soft-deleted mandate {mandateId}")
return True
def purgeExpiredMandates(self, retentionDays: int = 30) -> int:
"""Hard-delete all mandates whose soft-delete timestamp exceeds the retention period."""
import time
cutoff = time.time() - (retentionDays * 86400)
allMandates = self.db.getRecordset(Mandate)
purged = 0
for m in allMandates:
deletedAt = m.get("deletedAt") if isinstance(m, dict) else getattr(m, "deletedAt", None)
enabled = m.get("enabled") if isinstance(m, dict) else getattr(m, "enabled", True)
mandateId = m.get("id") if isinstance(m, dict) else getattr(m, "id", None)
if deletedAt and not enabled and deletedAt < cutoff and mandateId:
try:
self.deleteMandate(mandateId, force=True)
purged += 1
except Exception as e:
logger.error(f"Failed to purge expired mandate {mandateId}: {e}")
if purged:
logger.info(f"Purged {purged} expired mandate(s) beyond {retentionDays}-day retention")
return purged
# ============================================ # ============================================
# User-Mandate Membership Methods (Multi-Tenant) # User-Mandate Membership Methods (Multi-Tenant)
# ============================================ # ============================================
@ -1774,45 +1829,44 @@ class AppObjects:
Create a UserMandate record (add user to mandate). Create a UserMandate record (add user to mandate).
Also creates a billing account for the user if billing is configured for PREPAY_USER. Also creates a billing account for the user if billing is configured for PREPAY_USER.
INVARIANT: A UserMandate MUST have at least one UserMandateRole.
Args: Args:
userId: User ID userId: User ID
mandateId: Mandate ID mandateId: Mandate ID
roleIds: Optional list of role IDs to assign roleIds: List of role IDs to assign (at least one required)
Returns: Returns:
Created UserMandate object Created UserMandate object
""" """
if not roleIds:
raise ValueError(f"Cannot create UserMandate without roles for user {userId} in mandate {mandateId}")
try: try:
# Check if already exists
existing = self.getUserMandate(userId, mandateId) existing = self.getUserMandate(userId, mandateId)
if existing: if existing:
raise ValueError(f"User {userId} is already member of mandate {mandateId}") raise ValueError(f"User {userId} is already member of mandate {mandateId}")
# Subscription capacity check (before insert)
self._checkSubscriptionCapacity(mandateId, "users", delta=1) self._checkSubscriptionCapacity(mandateId, "users", delta=1)
# Create UserMandate
userMandate = UserMandate( userMandate = UserMandate(
userId=userId, userId=userId,
mandateId=mandateId, mandateId=mandateId,
enabled=True enabled=True
) )
createdRecord = self.db.recordCreate(UserMandate, userMandate.model_dump()) createdRecord = self.db.recordCreate(UserMandate, userMandate.model_dump())
if not createdRecord:
raise ValueError("Database failed to create UserMandate record")
# Assign roles via junction table userMandateId = createdRecord.get("id")
if roleIds and createdRecord: for roleId in roleIds:
userMandateId = createdRecord.get("id") userMandateRole = UserMandateRole(
for roleId in roleIds: userMandateId=userMandateId,
userMandateRole = UserMandateRole( roleId=roleId
userMandateId=userMandateId, )
roleId=roleId self.db.recordCreate(UserMandateRole, userMandateRole.model_dump())
)
self.db.recordCreate(UserMandateRole, userMandateRole.model_dump())
# Create billing account for user if billing is configured
self._ensureUserBillingAccount(userId, mandateId) self._ensureUserBillingAccount(userId, mandateId)
# Sync Stripe quantity after successful insert
self._syncSubscriptionQuantity(mandateId) self._syncSubscriptionQuantity(mandateId)
cleanedRecord = {k: v for k, v in createdRecord.items() if not k.startswith("_")} cleanedRecord = {k: v for k, v in createdRecord.items() if not k.startswith("_")}
@ -2198,40 +2252,42 @@ class AppObjects:
Create a FeatureAccess record (grant user access to feature instance). Create a FeatureAccess record (grant user access to feature instance).
Also auto-assigns the user to the mandate with the 'user' role if not already a member. Also auto-assigns the user to the mandate with the 'user' role if not already a member.
INVARIANT: A FeatureAccess MUST have at least one FeatureAccessRole.
Args: Args:
userId: User ID userId: User ID
featureInstanceId: FeatureInstance ID featureInstanceId: FeatureInstance ID
roleIds: Optional list of role IDs to assign roleIds: List of role IDs to assign (at least one required)
Returns: Returns:
Created FeatureAccess object Created FeatureAccess object
""" """
if not roleIds:
raise ValueError(f"Cannot create FeatureAccess without roles for user {userId} on instance {featureInstanceId}")
try: try:
# Check if already exists
existing = self.getFeatureAccess(userId, featureInstanceId) existing = self.getFeatureAccess(userId, featureInstanceId)
if existing: if existing:
raise ValueError(f"User {userId} already has access to feature instance {featureInstanceId}") raise ValueError(f"User {userId} already has access to feature instance {featureInstanceId}")
# Auto-assign user to mandate with 'user' role if not already a member
self._ensureUserMandateMembership(userId, featureInstanceId) self._ensureUserMandateMembership(userId, featureInstanceId)
# Create FeatureAccess
featureAccess = FeatureAccess( featureAccess = FeatureAccess(
userId=userId, userId=userId,
featureInstanceId=featureInstanceId, featureInstanceId=featureInstanceId,
enabled=True enabled=True
) )
createdRecord = self.db.recordCreate(FeatureAccess, featureAccess.model_dump()) createdRecord = self.db.recordCreate(FeatureAccess, featureAccess.model_dump())
if not createdRecord:
raise ValueError("Database failed to create FeatureAccess record")
# Assign roles via junction table featureAccessId = createdRecord.get("id")
if roleIds and createdRecord: for roleId in roleIds:
featureAccessId = createdRecord.get("id") featureAccessRole = FeatureAccessRole(
for roleId in roleIds: featureAccessId=featureAccessId,
featureAccessRole = FeatureAccessRole( roleId=roleId
featureAccessId=featureAccessId, )
roleId=roleId self.db.recordCreate(FeatureAccessRole, featureAccessRole.model_dump())
)
self.db.recordCreate(FeatureAccessRole, featureAccessRole.model_dump())
cleanedRecord = {k: v for k, v in createdRecord.items() if not k.startswith("_")} cleanedRecord = {k: v for k, v in createdRecord.items() if not k.startswith("_")}
return FeatureAccess(**cleanedRecord) return FeatureAccess(**cleanedRecord)
@ -2242,7 +2298,7 @@ class AppObjects:
def _ensureUserMandateMembership(self, userId: str, featureInstanceId: str) -> None: def _ensureUserMandateMembership(self, userId: str, featureInstanceId: str) -> None:
""" """
Ensure user is a member of the mandate that owns the feature instance. Ensure user is a member of the mandate that owns the feature instance.
If not already a member, adds them with the 'user' role (no access rights, membership only). If not already a member, adds them with the 'user' role.
""" """
try: try:
from modules.interfaces.interfaceFeatures import getFeatureInterface from modules.interfaces.interfaceFeatures import getFeatureInterface
@ -2255,28 +2311,30 @@ class AppObjects:
mandateId = str(instance.mandateId) mandateId = str(instance.mandateId)
# Check if user already has mandate membership
existing = self.getUserMandate(userId, mandateId) existing = self.getUserMandate(userId, mandateId)
if existing: if existing:
logger.debug(f"User {userId} already member of mandate {mandateId}") logger.debug(f"User {userId} already member of mandate {mandateId}")
return return
# Find the mandate-level 'user' role (membership marker, no access rights)
userRoles = self.db.getRecordset( userRoles = self.db.getRecordset(
Role, Role,
recordFilter={"roleLabel": "user", "mandateId": mandateId, "featureInstanceId": None} recordFilter={"roleLabel": "user", "mandateId": mandateId, "featureInstanceId": None}
) )
userRoleId = userRoles[0].get("id") if userRoles else None userRoleId = userRoles[0].get("id") if userRoles else None
roleIds = [userRoleId] if userRoleId else [] if not userRoleId:
raise ValueError(f"No 'user' role found for mandate {mandateId} — cannot assign user without role")
self.createUserMandate(userId, mandateId, roleIds) self.createUserMandate(userId, mandateId, roleIds=[userRoleId])
logger.info(f"Auto-assigned user {userId} to mandate {mandateId} with 'user' role (via feature instance {featureInstanceId})") logger.info(f"Auto-assigned user {userId} to mandate {mandateId} with 'user' role (via feature instance {featureInstanceId})")
except ValueError: except ValueError as ve:
# createUserMandate raises ValueError if already exists - safe to ignore if "already member" in str(ve):
pass pass
else:
raise
except Exception as e: except Exception as e:
logger.error(f"Error auto-assigning user {userId} to mandate: {e}") logger.error(f"Error auto-assigning user {userId} to mandate: {e}")
raise
def getRoleIdsForFeatureAccess(self, featureAccessId: str) -> List[str]: def getRoleIdsForFeatureAccess(self, featureAccessId: str) -> List[str]:
""" """

View file

@ -940,7 +940,12 @@ class ComponentObjects:
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:

View file

@ -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

View file

@ -7,12 +7,20 @@ Called once from bootstrap, sets a DB flag to prevent re-execution.
""" """
import logging import logging
from typing import Optional from typing import Optional, List, Dict, Any
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_MIGRATION_FLAG_KEY = "migration_root_users_completed" _MIGRATION_FLAG_KEY = "migration_root_users_completed"
_DATA_TABLES = [
"ChatWorkflow",
"FileItem",
"DataSource",
"DataNeutralizerAttributes",
"FileContentIndex",
]
def _isMigrationCompleted(db) -> bool: def _isMigrationCompleted(db) -> bool:
"""Check if migration has already been executed.""" """Check if migration has already been executed."""
@ -37,6 +45,95 @@ def _setMigrationCompleted(db) -> None:
logger.error(f"Failed to set migration flag: {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 "_createdBy" = %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: def migrateRootUsers(db, dryRun: bool = False) -> dict:
""" """
Migrate all end-user feature data from Root mandate to personal mandates. Migrate all end-user feature data from Root mandate to personal mandates.
@ -68,12 +165,15 @@ def migrateRootUsers(db, dryRun: bool = False) -> dict:
) )
from modules.datamodels.datamodelFeatures import FeatureInstance from modules.datamodels.datamodelFeatures import FeatureInstance
from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
from modules.interfaces.interfaceFeatures import getFeatureInterface
rootInterface = getRootInterface() rootInterface = getRootInterface()
featureInterface = getFeatureInterface(db)
stats = { stats = {
"usersProcessed": 0, "usersProcessed": 0,
"mandatesCreated": 0, "mandatesCreated": 0,
"instancesMigrated": 0, "instancesMigrated": 0,
"dataRowsMigrated": 0,
"rootInstancesDeleted": 0, "rootInstancesDeleted": 0,
"rootMembershipsRemoved": 0, "rootMembershipsRemoved": 0,
"dryRun": dryRun, "dryRun": dryRun,
@ -167,12 +267,29 @@ def migrateRootUsers(db, dryRun: bool = False) -> dict:
logger.info(f"[DRY RUN] Would migrate {featureCode} for {username} to mandate {targetMandateId}") logger.info(f"[DRY RUN] Would migrate {featureCode} for {username} to mandate {targetMandateId}")
stats["instancesMigrated"] += 1 stats["instancesMigrated"] += 1
else: else:
# Note: data migration (rewriting featureInstanceId on data records) is targetInstance = _findOrCreateTargetInstance(
# feature-specific and would need per-feature handlers. For now, we create db, featureInterface, featureCode, targetMandateId, instRecords[0],
# the new instance and transfer the access. Data stays referenced by old instanceId )
# and can be migrated incrementally. newInstanceId = targetInstance.get("id")
logger.info(f"Migrated access for {username} on {featureCode} (data migration deferred)") 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["instancesMigrated"] += 1
stats["dataRowsMigrated"] += migratedCount
stats["usersProcessed"] += 1 stats["usersProcessed"] += 1

View file

@ -1172,31 +1172,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 +1377,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 +1530,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
# ============================================================================= # =============================================================================

View file

@ -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
@ -699,6 +700,20 @@ def updateFileScope(
except Exception as e: except Exception as e:
logger.warning(f"Failed to update FileContentIndex scope for file {fileId}: {e}") logger.warning(f"Failed to update FileContentIndex scope for file {fileId}: {e}")
# Trigger re-indexing so RAG embeddings metadata reflects the new scope
try:
fileMeta = managementInterface.getFile(fileId)
if fileMeta:
import asyncio
asyncio.ensure_future(_autoIndexFile(
fileId=fileId,
fileName=fileMeta.fileName if hasattr(fileMeta, "fileName") else fileMeta.get("fileName", ""),
mimeType=fileMeta.mimeType if hasattr(fileMeta, "mimeType") else fileMeta.get("mimeType", ""),
user=context.user,
))
except Exception as e:
logger.warning(f"Failed to trigger re-index after scope change for file {fileId}: {e}")
return {"fileId": fileId, "scope": scope, "updated": True} return {"fileId": fileId, "scope": scope, "updated": True}
except HTTPException: except HTTPException:
raise raise
@ -725,6 +740,34 @@ def updateFileNeutralize(
managementInterface.updateFile(fileId, {"neutralize": neutralize}) managementInterface.updateFile(fileId, {"neutralize": neutralize})
# Update FileContentIndex neutralization metadata
try:
from modules.interfaces.interfaceDbKnowledge import getInterface as getKnowledgeInterface
from modules.datamodels.datamodelKnowledge import FileContentIndex
knowledgeDb = getKnowledgeInterface()
neutralizationStatus = "neutralized" if neutralize else "original"
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": neutralizationStatus})
except Exception as e:
logger.warning(f"Failed to update FileContentIndex neutralize for file {fileId}: {e}")
# Trigger re-indexing so content is re-processed with/without neutralization
try:
fileMeta = managementInterface.getFile(fileId)
if fileMeta:
import asyncio
asyncio.ensure_future(_autoIndexFile(
fileId=fileId,
fileName=fileMeta.fileName if hasattr(fileMeta, "fileName") else fileMeta.get("fileName", ""),
mimeType=fileMeta.mimeType if hasattr(fileMeta, "mimeType") else fileMeta.get("mimeType", ""),
user=context.user,
))
except Exception as e:
logger.warning(f"Failed to trigger re-index after neutralize change for file {fileId}: {e}")
return {"fileId": fileId, "neutralize": neutralize, "updated": True} return {"fileId": fileId, "neutralize": neutralize, "updated": True}
except Exception as e: except Exception as e:
logger.error(f"Error updating file neutralize flag: {e}") logger.error(f"Error updating file neutralize flag: {e}")
@ -799,6 +842,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(

View 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))

View file

@ -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}")

View file

@ -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, Query from fastapi import APIRouter, HTTPException, status, Depends, Request, Response, Body, Query, 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, MandateType from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority, Mandate, MandateType
@ -730,6 +730,13 @@ def onboarding_provision(
planKey=planKey, planKey=planKey,
) )
try:
activatedCount = appInterface._activatePendingSubscriptions(str(currentUser.id))
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}") logger.info(f"Onboarding provision for {currentUser.username}: {result}")
return { return {
"message": "Mandate provisioned successfully", "message": "Mandate provisioned successfully",
@ -922,3 +929,45 @@ async def testVoice(
).decode() ).decode()
return {"success": True, "audio": audioB64, "format": "mp3", "text": text} return {"success": True, "audio": audioB64, "format": "mp3", "text": text}
return {"success": False, "error": "TTS returned no audio"} return {"success": False, "error": "TTS returned no audio"}
# ============================================================
# 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}

View file

@ -36,7 +36,7 @@ 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") featureCode: str = Field(..., description="Feature code to activate")
mandateId: Optional[str] = Field(None, description="Target mandate ID (explicit). If None and user has no admin mandate, auto-creates personal mandate.") mandateId: str = Field(..., description="Target mandate ID — always explicit, never optional")
class StoreDeactivateRequest(BaseModel): class StoreDeactivateRequest(BaseModel):
@ -134,12 +134,27 @@ def listUserMandates(
request: Request, request: Request,
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""List mandates where the user can activate features (admin mandates).""" """
List mandates where the user can activate features (admin mandates).
If user has 0 admin mandates, auto-provisions a personal mandate so the
Store always has a clear mandate context.
"""
try: try:
rootInterface = getRootInterface() rootInterface = getRootInterface()
db = rootInterface.db db = rootInterface.db
userId = str(context.user.id) userId = str(context.user.id)
adminMandateIds = _getUserAdminMandateIds(db, userId) adminMandateIds = _getUserAdminMandateIds(db, userId)
if not adminMandateIds:
provisionResult = rootInterface._provisionMandateForUser(
userId=userId,
mandateType="personal",
mandateName=context.user.fullName or context.user.username,
planKey="TRIAL_7D",
)
adminMandateIds = [provisionResult["mandateId"]]
logger.info(f"Auto-provisioned personal mandate {adminMandateIds[0]} for user {userId} on Store access")
result = [] result = []
for mid in adminMandateIds: for mid in adminMandateIds:
records = db.getRecordset(Mandate, recordFilter={"id": mid}) records = db.getRecordset(Mandate, recordFilter={"id": mid})
@ -253,7 +268,7 @@ def activateStoreFeature(
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Activate a store feature. Creates a new FeatureInstance in the target mandate. Activate a store feature. Creates a new FeatureInstance in the target mandate.
If mandateId is None and user has no admin mandate, auto-creates a personal mandate. If user has no admin mandate, auto-creates a personal mandate.
""" """
featureCode = data.featureCode featureCode = data.featureCode
userId = str(context.user.id) userId = str(context.user.id)
@ -269,27 +284,6 @@ def activateStoreFeature(
mandateId = data.mandateId mandateId = data.mandateId
# Auto-create personal mandate if user has no admin mandates
if not mandateId:
adminMandateIds = _getUserAdminMandateIds(db, userId)
if not adminMandateIds:
provisionResult = rootInterface._provisionMandateForUser(
userId=userId,
mandateType="personal",
mandateName=context.user.fullName or context.user.username,
planKey="TRIAL_7D",
)
mandateId = provisionResult["mandateId"]
logger.info(f"Auto-created personal mandate {mandateId} for user {userId} via store")
elif len(adminMandateIds) == 1:
mandateId = adminMandateIds[0]
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="mandateId is required when user has multiple admin mandates"
)
# Verify user is admin in target mandate
if not _isUserAdminInMandate(db, userId, mandateId): if not _isUserAdminInMandate(db, userId, mandateId):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not admin in target mandate") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not admin in target mandate")
@ -323,19 +317,23 @@ def activateStoreFeature(
instanceId = instance.get("id") if isinstance(instance, dict) else instance.id instanceId = instance.get("id") if isinstance(instance, dict) else instance.id
# Grant FeatureAccess with admin role # Grant FeatureAccess with admin role — MUST be feature-specific (e.g. workspace-admin)
instanceRoles = db.getRecordset(Role, recordFilter={"featureInstanceId": instanceId}) instanceRoles = db.getRecordset(Role, recordFilter={"featureInstanceId": instanceId})
adminRoleId = None adminRoleId = None
for ir in instanceRoles: for ir in instanceRoles:
if "admin" in (ir.get("roleLabel") or "").lower(): roleLabel = (ir.get("roleLabel") or "").lower()
if roleLabel.endswith("-admin"):
adminRoleId = ir.get("id") adminRoleId = ir.get("id")
break break
fa = FeatureAccess(userId=userId, featureInstanceId=instanceId, enabled=True) if not adminRoleId:
createdFa = db.recordCreate(FeatureAccess, fa.model_dump()) raise HTTPException(
if adminRoleId and createdFa: status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
far = FeatureAccessRole(featureAccessId=createdFa["id"], roleId=adminRoleId) detail=f"No feature-specific admin role (e.g. {featureCode}-admin) found for instance {instanceId}. "
db.recordCreate(FeatureAccessRole, far.model_dump()) f"Template roles were not correctly copied.",
)
rootInterface.createFeatureAccess(userId, instanceId, roleIds=[adminRoleId])
# Sync subscription quantity # Sync subscription quantity
try: try:

View file

@ -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
@ -435,3 +435,59 @@ 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.datamodelSubscription import MandateSubscription, SubscriptionPlan
from modules.datamodels.datamodelFeature import FeatureInstance
rootIf = getRootInterface()
mandateId = targetMandateId
instances = rootIf.db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId})
totalBytes = 0
for inst in instances:
instId = inst.get("id") if isinstance(inst, dict) else getattr(inst, "id", None)
if not instId:
continue
files = rootIf.db.getRecordset(FileItem, recordFilter={"featureInstanceId": instId})
for f in files:
size = f.get("fileSize") if isinstance(f, dict) else getattr(f, "fileSize", 0)
totalBytes += (size or 0)
usedMB = round(totalBytes / (1024 * 1024), 2)
maxMB = None
subs = rootIf.db.getRecordset(MandateSubscription, recordFilter={"mandateId": mandateId})
for sub in subs:
planKey = sub.get("planKey") if isinstance(sub, dict) else getattr(sub, "planKey", "")
if planKey:
plans = rootIf.db.getRecordset(SubscriptionPlan, recordFilter={"planKey": planKey})
for plan in plans:
limit = plan.get("maxDataVolumeMB") if isinstance(plan, dict) else getattr(plan, "maxDataVolumeMB", None)
if limit:
maxMB = limit
break
if maxMB:
break
return {
"mandateId": mandateId,
"usedMB": usedMB,
"maxDataVolumeMB": maxMB,
"percentUsed": round((usedMB / maxMB) * 100, 1) if maxMB else None,
"warning": usedMB >= (maxMB * 0.8) if maxMB else False,
}

View file

@ -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

View file

@ -177,8 +177,11 @@ class AiService:
# Neutralize prompt if enabled (before AI call) # Neutralize prompt if enabled (before AI call)
_wasNeutralized = False _wasNeutralized = False
_excludedDocs: List[str] = []
if self._shouldNeutralize(request): if self._shouldNeutralize(request):
request, _wasNeutralized = self._neutralizeRequest(request) request, _wasNeutralized, _excludedDocs = self._neutralizeRequest(request)
if _excludedDocs:
logger.warning(f"Neutralization partial failures (continuing): {_excludedDocs}")
# Set billing callback on aiObjects BEFORE the AI call # Set billing callback on aiObjects BEFORE the AI call
# This callback is invoked by _callWithModel() after EVERY individual model call # This callback is invoked by _callWithModel() after EVERY individual model call
@ -199,6 +202,15 @@ class AiService:
if _wasNeutralized and response and hasattr(response, 'content') and response.content: if _wasNeutralized and response and hasattr(response, 'content') and response.content:
response.content = self._rehydrateResponse(response.content) response.content = self._rehydrateResponse(response.content)
# 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):
@ -217,15 +229,26 @@ class AiService:
# Neutralize prompt if enabled (before streaming) # Neutralize prompt if enabled (before streaming)
_wasNeutralized = False _wasNeutralized = False
_excludedDocs: List[str] = []
if self._shouldNeutralize(request): if self._shouldNeutralize(request):
request, _wasNeutralized = self._neutralizeRequest(request) request, _wasNeutralized, _excludedDocs = self._neutralizeRequest(request)
if _excludedDocs:
logger.warning(f"Neutralization partial failures in stream (continuing): {_excludedDocs}")
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):
# Rehydrate the final AiCallResponse (non-str chunks are the final response) # Rehydrate the final AiCallResponse (non-str chunks are the final response)
if _wasNeutralized and not isinstance(chunk, str) and hasattr(chunk, 'content') and chunk.content: if not isinstance(chunk, str):
chunk.content = self._rehydrateResponse(chunk.content) if _wasNeutralized and hasattr(chunk, 'content') and chunk.content:
chunk.content = self._rehydrateResponse(chunk.content)
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
@ -541,40 +564,71 @@ detectedIntent-Werte:
def _shouldNeutralize(self, request: AiCallRequest) -> bool: def _shouldNeutralize(self, request: AiCallRequest) -> bool:
"""Check if this AI request should have neutralization applied. """Check if this AI request should have neutralization applied.
Only applies to text prompts not embeddings or image processing.""" Per-request override: requireNeutralization=True forces it, False skips it.
Only applies to text prompts -- not embeddings or image processing."""
try: try:
if request.requireNeutralization is False:
return False
if not request.prompt and not request.messages:
return False
if request.requireNeutralization is True:
return True
neutralSvc = self._get_service("neutralization") neutralSvc = self._get_service("neutralization")
if not neutralSvc: if not neutralSvc:
return False return False
config = neutralSvc.getConfig() if hasattr(neutralSvc, 'getConfig') else None config = neutralSvc.getConfig() if hasattr(neutralSvc, 'getConfig') else None
if not config or not getattr(config, 'enabled', False): if not config or not getattr(config, 'enabled', False):
return False return False
if not request.prompt and not request.messages:
return False
return True return True
except Exception: except Exception:
return False return False
def _neutralizeRequest(self, request: AiCallRequest) -> Tuple[AiCallRequest, bool]: def _neutralizeRequest(self, request: AiCallRequest) -> Tuple[AiCallRequest, bool, List[str]]:
"""Neutralize the prompt text in an AiCallRequest. """Neutralize the prompt text and messages in an AiCallRequest.
Returns (modifiedRequest, wasNeutralized). Returns (modifiedRequest, wasNeutralized, excludedDocs).
Raises RuntimeError if neutralization is required but fails (fail-safe).""" Fail-safe: failing parts are excluded instead of aborting the entire call."""
excludedDocs: List[str] = []
neutralSvc = self._get_service("neutralization") neutralSvc = self._get_service("neutralization")
if not neutralSvc or not hasattr(neutralSvc, 'processText'): if not neutralSvc or not hasattr(neutralSvc, 'processText'):
raise RuntimeError("Neutralization required but neutralization service is unavailable") logger.warning("Neutralization required but neutralization service is unavailable — continuing without neutralization")
excludedDocs.append("Neutralization service unavailable; prompt sent un-neutralized")
return request, False, excludedDocs
_wasNeutralized = False
if request.prompt: if request.prompt:
result = neutralSvc.processText(request.prompt) try:
if result and result.get("neutralized_text"): result = neutralSvc.processText(request.prompt)
request.prompt = result["neutralized_text"] if result and result.get("neutralized_text"):
logger.debug("Neutralized prompt in AiCallRequest") request.prompt = result["neutralized_text"]
return request, True _wasNeutralized = True
raise RuntimeError( logger.debug("Neutralized prompt in AiCallRequest")
"Neutralization required but processText returned no neutralized_text — " else:
"AI call blocked to protect sensitive data" logger.warning("Neutralization of prompt returned no neutralized_text — sending original prompt")
) excludedDocs.append("Prompt neutralization failed; original prompt used")
except Exception as e:
logger.warning(f"Neutralization of prompt failed: {e} — sending original prompt")
excludedDocs.append(f"Prompt neutralization error: {e}")
return request, False if request.messages and isinstance(request.messages, list):
for idx, msg in enumerate(request.messages):
content = msg.get("content") if isinstance(msg, dict) else None
if not isinstance(content, str) or not content:
continue
try:
result = neutralSvc.processText(content)
if result and result.get("neutralized_text"):
msg["content"] = result["neutralized_text"]
_wasNeutralized = True
else:
logger.warning(f"Neutralization of message[{idx}] returned no neutralized_text — keeping original")
excludedDocs.append(f"Message[{idx}] neutralization failed; original kept")
except Exception as e:
logger.warning(f"Neutralization of message[{idx}] failed: {e} — keeping original")
excludedDocs.append(f"Message[{idx}] neutralization error: {e}")
return request, _wasNeutralized, excludedDocs
def _rehydrateResponse(self, responseText: str) -> str: def _rehydrateResponse(self, responseText: str) -> str:
"""Replace neutralization placeholders with original values in AI response.""" """Replace neutralization placeholders with original values in AI response."""

View file

@ -111,7 +111,7 @@ class KnowledgeService:
# 2. Chunk text content objects and create embeddings # 2. Chunk text content objects and create embeddings
textObjects = [o for o in contentObjects if o.get("contentType") == "text"] textObjects = [o for o in contentObjects if o.get("contentType") == "text"]
# Check if file requires neutralization # Read FileItem attributes for index metadata and neutralization
_shouldNeutralize = False _shouldNeutralize = False
try: try:
from modules.datamodels.datamodelFiles import FileItem as _FileItem from modules.datamodels.datamodelFiles import FileItem as _FileItem
@ -119,10 +119,14 @@ class KnowledgeService:
_fileRecords = _dbComponent.getRecordset(_FileItem, recordFilter={"id": fileId}) if _dbComponent else [] _fileRecords = _dbComponent.getRecordset(_FileItem, recordFilter={"id": fileId}) if _dbComponent else []
if _fileRecords: if _fileRecords:
_fileRecord = _fileRecords[0] _fileRecord = _fileRecords[0]
_shouldNeutralize = ( _get = (lambda k, d=None: _fileRecord.get(k, d)) if isinstance(_fileRecord, dict) else (lambda k, d=None: getattr(_fileRecord, k, d))
_fileRecord.get("neutralize", False) if isinstance(_fileRecord, dict) _shouldNeutralize = bool(_get("neutralize", False))
else getattr(_fileRecord, "neutralize", False) _fileScope = _get("scope")
) if _fileScope:
index.scope = _fileScope
_fileCreatedBy = _get("_createdBy")
if _fileCreatedBy:
index.userId = str(_fileCreatedBy)
except Exception: except Exception:
pass pass
@ -201,6 +205,7 @@ class KnowledgeService:
if _shouldNeutralize: if _shouldNeutralize:
try: try:
index.neutralizationStatus = "completed" index.neutralizationStatus = "completed"
index.isNeutralized = True
self._knowledgeDb.upsertFileContentIndex(index) self._knowledgeDb.upsertFileContentIndex(index)
except Exception as e: except Exception as e:
logger.debug(f"Could not set neutralizationStatus for file {fileId}: {e}") logger.debug(f"Could not set neutralizationStatus for file {fileId}: {e}")

View file

@ -258,27 +258,6 @@ def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguag
attributes.append(attr_def) attributes.append(attr_def)
# Append system timestamp fields (set automatically by DatabaseConnector)
systemTimestampFields = [
("_createdAt", {"en": "Created at", "de": "Erstellt am", "fr": "Créé le"}),
("_modifiedAt", {"en": "Modified at", "de": "Geändert am", "fr": "Modifié le"}),
]
for sysName, sysLabels in systemTimestampFields:
attributes.append({
"name": sysName,
"type": "timestamp",
"required": False,
"description": "",
"label": sysLabels.get(userLanguage, sysLabels["en"]),
"placeholder": "",
"editable": False,
"visible": True,
"order": len(attributes),
"readonly": True,
"options": None,
"default": None,
})
return {"model": model_label, "attributes": attributes} return {"model": model_label, "attributes": attributes}