fixing round 1
This commit is contained in:
parent
b33444e891
commit
efe540b4f9
24 changed files with 844 additions and 226 deletions
3
app.py
3
app.py
|
|
@ -545,6 +545,9 @@ app.include_router(userRouter)
|
|||
from modules.routes.routeDataFiles import router as 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
|
||||
app.include_router(promptRouter)
|
||||
|
||||
|
|
|
|||
|
|
@ -168,6 +168,7 @@ class AiCallRequest(BaseModel):
|
|||
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")
|
||||
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):
|
||||
|
|
|
|||
|
|
@ -25,6 +25,21 @@ class FeatureDataSource(BaseModel):
|
|||
userId: str = Field(default="", description="Owner user ID")
|
||||
workspaceInstanceId: str = Field(description="Workspace instance where this source is used")
|
||||
createdAt: float = Field(default_factory=getUtcTimestamp, description="Creation timestamp")
|
||||
scope: str = Field(
|
||||
default="personal",
|
||||
description="Data visibility scope: personal, featureInstance, mandate, global",
|
||||
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
|
||||
{"value": "personal", "label": {"en": "Personal", "de": "Persönlich"}},
|
||||
{"value": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}},
|
||||
{"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}},
|
||||
{"value": "global", "label": {"en": "Global", "de": "Global"}},
|
||||
]}
|
||||
)
|
||||
neutralize: bool = Field(
|
||||
default=False,
|
||||
description="Whether this data source should be neutralized before AI processing",
|
||||
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
|
||||
)
|
||||
|
||||
|
||||
registerModelLabels(
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@ class FileContentIndex(BaseModel):
|
|||
default=None,
|
||||
description="Neutralization status: completed, failed, skipped, None = not required",
|
||||
)
|
||||
isNeutralized: bool = Field(
|
||||
default=False,
|
||||
description="True if content was neutralized before indexing",
|
||||
)
|
||||
|
||||
|
||||
registerModelLabels(
|
||||
|
|
@ -64,6 +68,7 @@ registerModelLabels(
|
|||
"status": {"en": "Status", "fr": "Statut"},
|
||||
"scope": {"en": "Scope", "de": "Sichtbarkeit"},
|
||||
"neutralizationStatus": {"en": "Neutralization Status", "de": "Neutralisierungsstatus"},
|
||||
"isNeutralized": {"en": "Is Neutralized", "de": "Neutralisiert"},
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -103,6 +103,11 @@ class Mandate(BaseModel):
|
|||
{"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')
|
||||
@classmethod
|
||||
|
|
@ -135,6 +140,7 @@ registerModelLabels(
|
|||
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
|
||||
"isSystem": {"en": "System Mandate", "de": "System-Mandant", "fr": "Mandat système"},
|
||||
"mandateType": {"en": "Mandate Type", "de": "Mandantentyp", "fr": "Type de mandat"},
|
||||
"deletedAt": {"en": "Deleted at", "de": "Gelöscht am", "fr": "Supprimé le"},
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
# All rights reserved.
|
||||
"""
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Interface for Workspace feature — manages VoiceSettings and WorkspaceUserSettings.
|
||||
Interface for Workspace feature — manages WorkspaceUserSettings.
|
||||
Uses a dedicated poweron_workspace database.
|
||||
"""
|
||||
|
||||
|
|
|
|||
|
|
@ -893,6 +893,23 @@ async def listWorkspaceWorkflows(
|
|||
_validateInstanceAccess(instanceId, context)
|
||||
chatInterface = _getChatInterface(context, featureInstanceId=instanceId)
|
||||
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 = []
|
||||
for wf in workflows:
|
||||
if isinstance(wf, dict):
|
||||
|
|
@ -904,9 +921,15 @@ async def listWorkspaceWorkflows(
|
|||
"status": getattr(wf, "status", ""),
|
||||
"startedAt": getattr(wf, "startedAt", None),
|
||||
"lastActivity": getattr(wf, "lastActivity", None),
|
||||
"featureInstanceId": getattr(wf, "featureInstanceId", instanceId),
|
||||
}
|
||||
if not includeArchived and item.get("status") == "archived":
|
||||
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)
|
||||
return JSONResponse({"workflows": items})
|
||||
|
||||
|
|
|
|||
|
|
@ -133,6 +133,14 @@ def initBootstrap(db: DatabaseConnector) -> None:
|
|||
# Auto-provision Stripe Products/Prices for paid plans (idempotent)
|
||||
_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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -800,48 +800,6 @@ class AppObjects:
|
|||
logger.error(f"Error updating 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:
|
||||
"""Disables a user if current user has permission."""
|
||||
return self.updateUser(userId, {"enabled": False})
|
||||
|
|
@ -1493,11 +1451,10 @@ class AppObjects:
|
|||
adminRoleId = r.get("id")
|
||||
break
|
||||
|
||||
userMandate = UserMandate(userId=userId, mandateId=mandateId, enabled=True)
|
||||
createdUm = self.db.recordCreate(UserMandate, userMandate.model_dump())
|
||||
if adminRoleId and createdUm:
|
||||
umRole = UserMandateRole(userMandateId=createdUm["id"], roleId=adminRoleId)
|
||||
self.db.recordCreate(UserMandateRole, umRole.model_dump())
|
||||
if not adminRoleId:
|
||||
raise ValueError(f"No admin role found for mandate {mandateId} — cannot assign user without role")
|
||||
|
||||
self.createUserMandate(userId, mandateId, roleIds=[adminRoleId])
|
||||
|
||||
subscription = MandateSubscription(
|
||||
mandateId=mandateId,
|
||||
|
|
@ -1533,14 +1490,16 @@ class AppObjects:
|
|||
instanceRoles = self.db.getRecordset(Role, recordFilter={"featureInstanceId": instanceId})
|
||||
adminInstRoleId = None
|
||||
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")
|
||||
break
|
||||
fa = FeatureAccess(userId=userId, featureInstanceId=instanceId, enabled=True)
|
||||
createdFa = self.db.recordCreate(FeatureAccess, fa.model_dump())
|
||||
if adminInstRoleId and createdFa:
|
||||
far = FeatureAccessRole(featureAccessId=createdFa["id"], roleId=adminInstRoleId)
|
||||
self.db.recordCreate(FeatureAccessRole, far.model_dump())
|
||||
if not adminInstRoleId:
|
||||
raise ValueError(
|
||||
f"No feature-specific admin role (e.g. {featureCode}-admin) for instance {instanceId}. "
|
||||
f"Template roles not synced for feature '{featureCode}'."
|
||||
)
|
||||
self.createFeatureAccess(userId, instanceId, roleIds=[adminInstRoleId])
|
||||
except Exception as 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}")
|
||||
|
||||
if not force:
|
||||
self.db.recordModify(Mandate, mandateId, {"enabled": False})
|
||||
logger.info(f"Soft-deleted mandate {mandateId}")
|
||||
from modules.shared.timeUtils import getUtcTimestamp
|
||||
self.db.recordModify(Mandate, mandateId, {"enabled": False, "deletedAt": getUtcTimestamp()})
|
||||
logger.info(f"Soft-deleted mandate {mandateId} (30-day retention)")
|
||||
return True
|
||||
|
||||
# Hard delete with cascade
|
||||
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
|
||||
instances = self.db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId})
|
||||
for inst in instances:
|
||||
instId = inst.get("id")
|
||||
accesses = self.db.getRecordset(FeatureAccess, recordFilter={"featureInstanceId": instId})
|
||||
|
|
@ -1692,10 +1708,20 @@ class AppObjects:
|
|||
self.db.recordDelete(UserMandate, um.get("id"))
|
||||
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})
|
||||
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}")
|
||||
|
||||
# 4. Delete mandate-level Roles
|
||||
|
|
@ -1717,6 +1743,35 @@ class AppObjects:
|
|||
logger.error(f"Error deleting 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)
|
||||
# ============================================
|
||||
|
|
@ -1774,45 +1829,44 @@ class AppObjects:
|
|||
Create a UserMandate record (add user to mandate).
|
||||
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:
|
||||
userId: User ID
|
||||
mandateId: Mandate ID
|
||||
roleIds: Optional list of role IDs to assign
|
||||
roleIds: List of role IDs to assign (at least one required)
|
||||
|
||||
Returns:
|
||||
Created UserMandate object
|
||||
"""
|
||||
if not roleIds:
|
||||
raise ValueError(f"Cannot create UserMandate without roles for user {userId} in mandate {mandateId}")
|
||||
|
||||
try:
|
||||
# Check if already exists
|
||||
existing = self.getUserMandate(userId, mandateId)
|
||||
if existing:
|
||||
raise ValueError(f"User {userId} is already member of mandate {mandateId}")
|
||||
|
||||
# Subscription capacity check (before insert)
|
||||
self._checkSubscriptionCapacity(mandateId, "users", delta=1)
|
||||
|
||||
# Create UserMandate
|
||||
userMandate = UserMandate(
|
||||
userId=userId,
|
||||
mandateId=mandateId,
|
||||
enabled=True
|
||||
)
|
||||
createdRecord = self.db.recordCreate(UserMandate, userMandate.model_dump())
|
||||
if not createdRecord:
|
||||
raise ValueError("Database failed to create UserMandate record")
|
||||
|
||||
# Assign roles via junction table
|
||||
if roleIds and createdRecord:
|
||||
userMandateId = createdRecord.get("id")
|
||||
for roleId in roleIds:
|
||||
userMandateRole = UserMandateRole(
|
||||
userMandateId=userMandateId,
|
||||
roleId=roleId
|
||||
)
|
||||
self.db.recordCreate(UserMandateRole, userMandateRole.model_dump())
|
||||
userMandateId = createdRecord.get("id")
|
||||
for roleId in roleIds:
|
||||
userMandateRole = UserMandateRole(
|
||||
userMandateId=userMandateId,
|
||||
roleId=roleId
|
||||
)
|
||||
self.db.recordCreate(UserMandateRole, userMandateRole.model_dump())
|
||||
|
||||
# Create billing account for user if billing is configured
|
||||
self._ensureUserBillingAccount(userId, mandateId)
|
||||
|
||||
# Sync Stripe quantity after successful insert
|
||||
self._syncSubscriptionQuantity(mandateId)
|
||||
|
||||
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).
|
||||
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:
|
||||
userId: User ID
|
||||
featureInstanceId: FeatureInstance ID
|
||||
roleIds: Optional list of role IDs to assign
|
||||
roleIds: List of role IDs to assign (at least one required)
|
||||
|
||||
Returns:
|
||||
Created FeatureAccess object
|
||||
"""
|
||||
if not roleIds:
|
||||
raise ValueError(f"Cannot create FeatureAccess without roles for user {userId} on instance {featureInstanceId}")
|
||||
|
||||
try:
|
||||
# Check if already exists
|
||||
existing = self.getFeatureAccess(userId, featureInstanceId)
|
||||
if existing:
|
||||
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)
|
||||
|
||||
# Create FeatureAccess
|
||||
featureAccess = FeatureAccess(
|
||||
userId=userId,
|
||||
featureInstanceId=featureInstanceId,
|
||||
enabled=True
|
||||
)
|
||||
createdRecord = self.db.recordCreate(FeatureAccess, featureAccess.model_dump())
|
||||
if not createdRecord:
|
||||
raise ValueError("Database failed to create FeatureAccess record")
|
||||
|
||||
# Assign roles via junction table
|
||||
if roleIds and createdRecord:
|
||||
featureAccessId = createdRecord.get("id")
|
||||
for roleId in roleIds:
|
||||
featureAccessRole = FeatureAccessRole(
|
||||
featureAccessId=featureAccessId,
|
||||
roleId=roleId
|
||||
)
|
||||
self.db.recordCreate(FeatureAccessRole, featureAccessRole.model_dump())
|
||||
featureAccessId = createdRecord.get("id")
|
||||
for roleId in roleIds:
|
||||
featureAccessRole = FeatureAccessRole(
|
||||
featureAccessId=featureAccessId,
|
||||
roleId=roleId
|
||||
)
|
||||
self.db.recordCreate(FeatureAccessRole, featureAccessRole.model_dump())
|
||||
|
||||
cleanedRecord = {k: v for k, v in createdRecord.items() if not k.startswith("_")}
|
||||
return FeatureAccess(**cleanedRecord)
|
||||
|
|
@ -2242,7 +2298,7 @@ class AppObjects:
|
|||
def _ensureUserMandateMembership(self, userId: str, featureInstanceId: str) -> None:
|
||||
"""
|
||||
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:
|
||||
from modules.interfaces.interfaceFeatures import getFeatureInterface
|
||||
|
|
@ -2255,28 +2311,30 @@ class AppObjects:
|
|||
|
||||
mandateId = str(instance.mandateId)
|
||||
|
||||
# Check if user already has mandate membership
|
||||
existing = self.getUserMandate(userId, mandateId)
|
||||
if existing:
|
||||
logger.debug(f"User {userId} already member of mandate {mandateId}")
|
||||
return
|
||||
|
||||
# Find the mandate-level 'user' role (membership marker, no access rights)
|
||||
userRoles = self.db.getRecordset(
|
||||
Role,
|
||||
recordFilter={"roleLabel": "user", "mandateId": mandateId, "featureInstanceId": 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})")
|
||||
|
||||
except ValueError:
|
||||
# createUserMandate raises ValueError if already exists - safe to ignore
|
||||
pass
|
||||
except ValueError as ve:
|
||||
if "already member" in str(ve):
|
||||
pass
|
||||
else:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error auto-assigning user {userId} to mandate: {e}")
|
||||
raise
|
||||
|
||||
def getRoleIdsForFeatureAccess(self, featureAccessId: str) -> List[str]:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -940,7 +940,12 @@ class ComponentObjects:
|
|||
fileName = file.get("fileName")
|
||||
if not fileName or fileName == "None":
|
||||
continue
|
||||
|
||||
|
||||
if file.get("scope") is None:
|
||||
file["scope"] = "personal"
|
||||
if file.get("neutralize") is None:
|
||||
file["neutralize"] = False
|
||||
|
||||
fileItem = FileItem(**file)
|
||||
fileItems.append(fileItem)
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -208,7 +208,11 @@ class FeatureInterface:
|
|||
|
||||
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:
|
||||
featureCode: Feature code
|
||||
|
|
@ -217,19 +221,30 @@ class FeatureInterface:
|
|||
|
||||
Returns:
|
||||
Number of roles copied
|
||||
|
||||
Raises:
|
||||
ValueError: If no feature-specific template roles exist
|
||||
"""
|
||||
try:
|
||||
# Find global template roles for this feature (mandateId=None)
|
||||
globalRoles = self.db.getRecordset(
|
||||
allTemplates = self.db.getRecordset(
|
||||
Role,
|
||||
recordFilter={"featureCode": featureCode, "mandateId": None}
|
||||
recordFilter={"featureCode": featureCode}
|
||||
)
|
||||
|
||||
if not globalRoles:
|
||||
logger.debug(f"No template roles found for feature {featureCode}")
|
||||
return 0
|
||||
featureTemplates = [
|
||||
r for r in allTemplates
|
||||
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
|
||||
allTemplateRules = []
|
||||
|
|
@ -246,7 +261,7 @@ class FeatureInterface:
|
|||
|
||||
# Copy roles and their AccessRules
|
||||
copiedCount = 0
|
||||
for templateRole in globalRoles:
|
||||
for templateRole in featureTemplates:
|
||||
newRoleId = str(uuid.uuid4())
|
||||
|
||||
# Create new role for this instance
|
||||
|
|
@ -282,9 +297,11 @@ class FeatureInterface:
|
|||
logger.info(f"Copied {copiedCount} template roles for instance {instanceId}")
|
||||
return copiedCount
|
||||
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as 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]:
|
||||
"""
|
||||
|
|
@ -309,11 +326,15 @@ class FeatureInterface:
|
|||
featureCode = instance.featureCode
|
||||
mandateId = instance.mandateId
|
||||
|
||||
# Get current template roles
|
||||
templateRoles = self.db.getRecordset(
|
||||
# Get feature-specific template roles (mandateId=None, featureInstanceId=None)
|
||||
allForFeature = self.db.getRecordset(
|
||||
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}
|
||||
|
||||
# Get current instance roles
|
||||
|
|
|
|||
|
|
@ -7,12 +7,20 @@ Called once from bootstrap, sets a DB flag to prevent re-execution.
|
|||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MIGRATION_FLAG_KEY = "migration_root_users_completed"
|
||||
|
||||
_DATA_TABLES = [
|
||||
"ChatWorkflow",
|
||||
"FileItem",
|
||||
"DataSource",
|
||||
"DataNeutralizerAttributes",
|
||||
"FileContentIndex",
|
||||
]
|
||||
|
||||
|
||||
def _isMigrationCompleted(db) -> bool:
|
||||
"""Check if migration has already been executed."""
|
||||
|
|
@ -37,6 +45,95 @@ def _setMigrationCompleted(db) -> None:
|
|||
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:
|
||||
"""
|
||||
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.interfaces.interfaceDbApp import getRootInterface
|
||||
from modules.interfaces.interfaceFeatures import getFeatureInterface
|
||||
|
||||
rootInterface = getRootInterface()
|
||||
featureInterface = getFeatureInterface(db)
|
||||
stats = {
|
||||
"usersProcessed": 0,
|
||||
"mandatesCreated": 0,
|
||||
"instancesMigrated": 0,
|
||||
"dataRowsMigrated": 0,
|
||||
"rootInstancesDeleted": 0,
|
||||
"rootMembershipsRemoved": 0,
|
||||
"dryRun": dryRun,
|
||||
|
|
@ -167,12 +267,29 @@ def migrateRootUsers(db, dryRun: bool = False) -> dict:
|
|||
logger.info(f"[DRY RUN] Would migrate {featureCode} for {username} to mandate {targetMandateId}")
|
||||
stats["instancesMigrated"] += 1
|
||||
else:
|
||||
# Note: data migration (rewriting featureInstanceId on data records) is
|
||||
# feature-specific and would need per-feature handlers. For now, we create
|
||||
# the new instance and transfer the access. Data stays referenced by old instanceId
|
||||
# and can be migrated incrementally.
|
||||
logger.info(f"Migrated access for {username} on {featureCode} (data migration deferred)")
|
||||
targetInstance = _findOrCreateTargetInstance(
|
||||
db, featureInterface, featureCode, targetMandateId, instRecords[0],
|
||||
)
|
||||
newInstanceId = targetInstance.get("id")
|
||||
if not newInstanceId:
|
||||
logger.error(f"Failed to obtain target instance for {featureCode} in mandate {targetMandateId}")
|
||||
continue
|
||||
|
||||
migratedCount = _migrateDataRecords(db, oldInstanceId, newInstanceId, userId)
|
||||
|
||||
_grantFeatureAccess(db, userId, newInstanceId)
|
||||
|
||||
try:
|
||||
db.recordDelete(FeatureAccess, oldAccessId)
|
||||
except Exception as delErr:
|
||||
logger.warning(f"Could not remove old FeatureAccess {oldAccessId}: {delErr}")
|
||||
|
||||
logger.info(
|
||||
f"Migrated {featureCode} for {username}: "
|
||||
f"instance {oldInstanceId} -> {newInstanceId}, {migratedCount} data rows moved"
|
||||
)
|
||||
stats["instancesMigrated"] += 1
|
||||
stats["dataRowsMigrated"] += migratedCount
|
||||
|
||||
stats["usersProcessed"] += 1
|
||||
|
||||
|
|
|
|||
|
|
@ -1172,31 +1172,29 @@ def add_user_to_feature_instance(
|
|||
detail=f"User '{data.userId}' not found"
|
||||
)
|
||||
|
||||
# Check if user already has access
|
||||
from modules.datamodels.datamodelMembership import FeatureAccess, FeatureAccessRole
|
||||
existingAccess = rootInterface.getFeatureAccess(data.userId, instanceId)
|
||||
if existingAccess:
|
||||
if not data.roleIds:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="User already has access to this feature instance"
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="At least one role is required to grant feature access"
|
||||
)
|
||||
|
||||
# Create FeatureAccess record
|
||||
featureAccess = FeatureAccess(
|
||||
|
||||
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."
|
||||
)
|
||||
|
||||
featureAccess = rootInterface.createFeatureAccess(
|
||||
userId=data.userId,
|
||||
featureInstanceId=instanceId,
|
||||
enabled=True
|
||||
roleIds=data.roleIds
|
||||
)
|
||||
createdAccess = rootInterface.db.recordCreate(FeatureAccess, featureAccess.model_dump())
|
||||
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())
|
||||
featureAccessId = str(featureAccess.id)
|
||||
|
||||
logger.info(
|
||||
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:
|
||||
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)
|
||||
|
||||
# Create new FeatureAccessRole records
|
||||
for roleId in data.roleIds:
|
||||
featureAccessRole = FeatureAccessRole(
|
||||
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
|
||||
# =============================================================================
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import json
|
|||
|
||||
# Import auth module
|
||||
from modules.auth import limiter, getCurrentUser, getRequestContext, RequestContext
|
||||
from modules.auth.authentication import _hasSysAdminRole
|
||||
|
||||
# Import interfaces
|
||||
import modules.interfaces.interfaceDbManagement as interfaceDbManagement
|
||||
|
|
@ -699,6 +700,20 @@ def updateFileScope(
|
|||
except Exception as 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}
|
||||
except HTTPException:
|
||||
raise
|
||||
|
|
@ -725,6 +740,34 @@ def updateFileNeutralize(
|
|||
|
||||
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}
|
||||
except Exception as 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"
|
||||
)
|
||||
|
||||
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
|
||||
if not managementInterface.checkRbacPermission(FileItem, "update", fileId):
|
||||
raise HTTPException(
|
||||
|
|
|
|||
97
modules/routes/routeDataSources.py
Normal file
97
modules/routes/routeDataSources.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""PATCH endpoints for DataSource and FeatureDataSource scope/neutralize tagging."""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Path, Request, Body
|
||||
from modules.auth import limiter, getRequestContext, RequestContext
|
||||
from modules.auth.authentication import _hasSysAdminRole
|
||||
from modules.datamodels.datamodelDataSource import DataSource
|
||||
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/datasources",
|
||||
tags=["Data Sources"],
|
||||
responses={
|
||||
404: {"description": "Not found"},
|
||||
400: {"description": "Bad request"},
|
||||
401: {"description": "Unauthorized"},
|
||||
403: {"description": "Forbidden"},
|
||||
500: {"description": "Internal server error"},
|
||||
},
|
||||
)
|
||||
|
||||
_VALID_SCOPES = {"personal", "featureInstance", "mandate", "global"}
|
||||
|
||||
|
||||
def _findSourceRecord(db, sourceId: str):
|
||||
"""Look up a source by ID, checking DataSource first, then FeatureDataSource."""
|
||||
rec = db.getRecord(DataSource, sourceId)
|
||||
if rec:
|
||||
return rec, DataSource
|
||||
rec = db.getRecord(FeatureDataSource, sourceId)
|
||||
if rec:
|
||||
return rec, FeatureDataSource
|
||||
return None, None
|
||||
|
||||
|
||||
@router.patch("/{sourceId}/scope")
|
||||
@limiter.limit("30/minute")
|
||||
def _updateDataSourceScope(
|
||||
request: Request,
|
||||
sourceId: str = Path(..., description="ID of the DataSource or FeatureDataSource"),
|
||||
scope: str = Body(..., embed=True),
|
||||
context: RequestContext = Depends(getRequestContext),
|
||||
) -> Dict[str, Any]:
|
||||
"""Update the scope of a DataSource or FeatureDataSource. Global scope requires sysAdmin."""
|
||||
if scope not in _VALID_SCOPES:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid scope: {scope}. Must be one of {_VALID_SCOPES}")
|
||||
|
||||
if scope == "global" and not _hasSysAdminRole(context.user):
|
||||
raise HTTPException(status_code=403, detail="Only sysadmins can set global scope")
|
||||
|
||||
try:
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
rootIf = getRootInterface()
|
||||
rec, model = _findSourceRecord(rootIf.db, sourceId)
|
||||
if not rec:
|
||||
raise HTTPException(status_code=404, detail=f"DataSource {sourceId} not found")
|
||||
|
||||
rootIf.db.recordModify(model, sourceId, {"scope": scope})
|
||||
logger.info("Updated scope=%s for %s %s", scope, model.__name__, sourceId)
|
||||
return {"sourceId": sourceId, "scope": scope, "updated": True}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error updating datasource scope: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/{sourceId}/neutralize")
|
||||
@limiter.limit("30/minute")
|
||||
def _updateDataSourceNeutralize(
|
||||
request: Request,
|
||||
sourceId: str = Path(..., description="ID of the DataSource or FeatureDataSource"),
|
||||
neutralize: bool = Body(..., embed=True),
|
||||
context: RequestContext = Depends(getRequestContext),
|
||||
) -> Dict[str, Any]:
|
||||
"""Toggle the neutralization flag on a DataSource or FeatureDataSource."""
|
||||
try:
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
rootIf = getRootInterface()
|
||||
rec, model = _findSourceRecord(rootIf.db, sourceId)
|
||||
if not rec:
|
||||
raise HTTPException(status_code=404, detail=f"DataSource {sourceId} not found")
|
||||
|
||||
rootIf.db.recordModify(model, sourceId, {"neutralize": neutralize})
|
||||
logger.info("Updated neutralize=%s for %s %s", neutralize, model.__name__, sourceId)
|
||||
return {"sourceId": sourceId, "neutralize": neutralize, "updated": True}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error updating datasource neutralize: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
|
@ -639,14 +639,17 @@ def create_user(
|
|||
|
||||
# MULTI-TENANT: Add user to current mandate via UserMandate with default "user" role
|
||||
if context.mandateId:
|
||||
# Get "user" role ID
|
||||
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(
|
||||
userId=str(newUser.id),
|
||||
mandateId=str(context.mandateId),
|
||||
roleIds=roleIds
|
||||
roleIds=[str(userRole.id)]
|
||||
)
|
||||
logger.info(f"Created UserMandate for user {newUser.id} in mandate {context.mandateId}")
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
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
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
|
|
@ -14,7 +14,7 @@ import uuid
|
|||
from jose import jwt
|
||||
|
||||
# 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.interfaces.interfaceDbApp import getInterface, getRootInterface
|
||||
from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority, Mandate, MandateType
|
||||
|
|
@ -730,6 +730,13 @@ def onboarding_provision(
|
|||
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}")
|
||||
return {
|
||||
"message": "Mandate provisioned successfully",
|
||||
|
|
@ -922,3 +929,45 @@ async def testVoice(
|
|||
).decode()
|
||||
return {"success": True, "audio": audioB64, "format": "mp3", "text": text}
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ router = APIRouter(
|
|||
class StoreActivateRequest(BaseModel):
|
||||
"""Request model for activating a store feature."""
|
||||
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):
|
||||
|
|
@ -134,12 +134,27 @@ def listUserMandates(
|
|||
request: Request,
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> 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:
|
||||
rootInterface = getRootInterface()
|
||||
db = rootInterface.db
|
||||
userId = str(context.user.id)
|
||||
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 = []
|
||||
for mid in adminMandateIds:
|
||||
records = db.getRecordset(Mandate, recordFilter={"id": mid})
|
||||
|
|
@ -253,7 +268,7 @@ def activateStoreFeature(
|
|||
) -> Dict[str, Any]:
|
||||
"""
|
||||
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
|
||||
userId = str(context.user.id)
|
||||
|
|
@ -269,27 +284,6 @@ def activateStoreFeature(
|
|||
|
||||
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):
|
||||
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
|
||||
|
||||
# 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})
|
||||
adminRoleId = None
|
||||
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")
|
||||
break
|
||||
|
||||
fa = FeatureAccess(userId=userId, featureInstanceId=instanceId, enabled=True)
|
||||
createdFa = db.recordCreate(FeatureAccess, fa.model_dump())
|
||||
if adminRoleId and createdFa:
|
||||
far = FeatureAccessRole(featureAccessId=createdFa["id"], roleId=adminRoleId)
|
||||
db.recordCreate(FeatureAccessRole, far.model_dump())
|
||||
if not adminRoleId:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"No feature-specific admin role (e.g. {featureCode}-admin) found for instance {instanceId}. "
|
||||
f"Template roles were not correctly copied.",
|
||||
)
|
||||
|
||||
rootInterface.createFeatureAccess(userId, instanceId, roleIds=[adminRoleId])
|
||||
|
||||
# Sync subscription quantity
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ Endpoints:
|
|||
- 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 typing import Dict, Any, List, Optional
|
||||
import logging
|
||||
|
|
@ -435,3 +435,59 @@ def getFilterValues(
|
|||
crossFiltered = _applyFiltersAndSort(enriched, crossFilterParams)
|
||||
|
||||
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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -247,12 +247,12 @@ def _buildDynamicBlock(
|
|||
# Sort views by order
|
||||
views.sort(key=lambda v: v["order"])
|
||||
|
||||
# Add instance to feature
|
||||
featuresMap[featureKey]["instances"].append({
|
||||
"id": str(instance.id),
|
||||
"uiLabel": instance.label,
|
||||
"order": 10,
|
||||
"views": views
|
||||
"views": views,
|
||||
"isAdmin": permissions.get("isAdmin", False),
|
||||
})
|
||||
|
||||
# Build final structure
|
||||
|
|
|
|||
|
|
@ -177,8 +177,11 @@ class AiService:
|
|||
|
||||
# Neutralize prompt if enabled (before AI call)
|
||||
_wasNeutralized = False
|
||||
_excludedDocs: List[str] = []
|
||||
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
|
||||
# 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:
|
||||
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
|
||||
|
||||
async def callAiStream(self, request: AiCallRequest):
|
||||
|
|
@ -217,15 +229,26 @@ class AiService:
|
|||
|
||||
# Neutralize prompt if enabled (before streaming)
|
||||
_wasNeutralized = False
|
||||
_excludedDocs: List[str] = []
|
||||
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()
|
||||
try:
|
||||
async for chunk in self.aiObjects.callWithTextContextStream(request):
|
||||
# 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:
|
||||
chunk.content = self._rehydrateResponse(chunk.content)
|
||||
if not isinstance(chunk, str):
|
||||
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
|
||||
finally:
|
||||
self.aiObjects.billingCallback = None
|
||||
|
|
@ -541,40 +564,71 @@ detectedIntent-Werte:
|
|||
|
||||
def _shouldNeutralize(self, request: AiCallRequest) -> bool:
|
||||
"""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:
|
||||
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")
|
||||
if not neutralSvc:
|
||||
return False
|
||||
config = neutralSvc.getConfig() if hasattr(neutralSvc, 'getConfig') else None
|
||||
if not config or not getattr(config, 'enabled', False):
|
||||
return False
|
||||
if not request.prompt and not request.messages:
|
||||
return False
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _neutralizeRequest(self, request: AiCallRequest) -> Tuple[AiCallRequest, bool]:
|
||||
"""Neutralize the prompt text in an AiCallRequest.
|
||||
Returns (modifiedRequest, wasNeutralized).
|
||||
Raises RuntimeError if neutralization is required but fails (fail-safe)."""
|
||||
def _neutralizeRequest(self, request: AiCallRequest) -> Tuple[AiCallRequest, bool, List[str]]:
|
||||
"""Neutralize the prompt text and messages in an AiCallRequest.
|
||||
Returns (modifiedRequest, wasNeutralized, excludedDocs).
|
||||
Fail-safe: failing parts are excluded instead of aborting the entire call."""
|
||||
excludedDocs: List[str] = []
|
||||
|
||||
neutralSvc = self._get_service("neutralization")
|
||||
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:
|
||||
result = neutralSvc.processText(request.prompt)
|
||||
if result and result.get("neutralized_text"):
|
||||
request.prompt = result["neutralized_text"]
|
||||
logger.debug("Neutralized prompt in AiCallRequest")
|
||||
return request, True
|
||||
raise RuntimeError(
|
||||
"Neutralization required but processText returned no neutralized_text — "
|
||||
"AI call blocked to protect sensitive data"
|
||||
)
|
||||
try:
|
||||
result = neutralSvc.processText(request.prompt)
|
||||
if result and result.get("neutralized_text"):
|
||||
request.prompt = result["neutralized_text"]
|
||||
_wasNeutralized = True
|
||||
logger.debug("Neutralized prompt in AiCallRequest")
|
||||
else:
|
||||
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:
|
||||
"""Replace neutralization placeholders with original values in AI response."""
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ class KnowledgeService:
|
|||
# 2. Chunk text content objects and create embeddings
|
||||
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
|
||||
try:
|
||||
from modules.datamodels.datamodelFiles import FileItem as _FileItem
|
||||
|
|
@ -119,10 +119,14 @@ class KnowledgeService:
|
|||
_fileRecords = _dbComponent.getRecordset(_FileItem, recordFilter={"id": fileId}) if _dbComponent else []
|
||||
if _fileRecords:
|
||||
_fileRecord = _fileRecords[0]
|
||||
_shouldNeutralize = (
|
||||
_fileRecord.get("neutralize", False) if isinstance(_fileRecord, dict)
|
||||
else getattr(_fileRecord, "neutralize", False)
|
||||
)
|
||||
_get = (lambda k, d=None: _fileRecord.get(k, d)) if isinstance(_fileRecord, dict) else (lambda k, d=None: getattr(_fileRecord, k, d))
|
||||
_shouldNeutralize = bool(_get("neutralize", False))
|
||||
_fileScope = _get("scope")
|
||||
if _fileScope:
|
||||
index.scope = _fileScope
|
||||
_fileCreatedBy = _get("_createdBy")
|
||||
if _fileCreatedBy:
|
||||
index.userId = str(_fileCreatedBy)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
@ -201,6 +205,7 @@ class KnowledgeService:
|
|||
if _shouldNeutralize:
|
||||
try:
|
||||
index.neutralizationStatus = "completed"
|
||||
index.isNeutralized = True
|
||||
self._knowledgeDb.upsertFileContentIndex(index)
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not set neutralizationStatus for file {fileId}: {e}")
|
||||
|
|
|
|||
|
|
@ -258,27 +258,6 @@ def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguag
|
|||
|
||||
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}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue