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
|
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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -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]:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -941,6 +941,11 @@ class ComponentObjects:
|
||||||
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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
97
modules/routes/routeDataSources.py
Normal file
97
modules/routes/routeDataSources.py
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""PATCH endpoints for DataSource and FeatureDataSource scope/neutralize tagging."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends, Path, Request, Body
|
||||||
|
from modules.auth import limiter, getRequestContext, RequestContext
|
||||||
|
from modules.auth.authentication import _hasSysAdminRole
|
||||||
|
from modules.datamodels.datamodelDataSource import DataSource
|
||||||
|
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/api/datasources",
|
||||||
|
tags=["Data Sources"],
|
||||||
|
responses={
|
||||||
|
404: {"description": "Not found"},
|
||||||
|
400: {"description": "Bad request"},
|
||||||
|
401: {"description": "Unauthorized"},
|
||||||
|
403: {"description": "Forbidden"},
|
||||||
|
500: {"description": "Internal server error"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
_VALID_SCOPES = {"personal", "featureInstance", "mandate", "global"}
|
||||||
|
|
||||||
|
|
||||||
|
def _findSourceRecord(db, sourceId: str):
|
||||||
|
"""Look up a source by ID, checking DataSource first, then FeatureDataSource."""
|
||||||
|
rec = db.getRecord(DataSource, sourceId)
|
||||||
|
if rec:
|
||||||
|
return rec, DataSource
|
||||||
|
rec = db.getRecord(FeatureDataSource, sourceId)
|
||||||
|
if rec:
|
||||||
|
return rec, FeatureDataSource
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{sourceId}/scope")
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
def _updateDataSourceScope(
|
||||||
|
request: Request,
|
||||||
|
sourceId: str = Path(..., description="ID of the DataSource or FeatureDataSource"),
|
||||||
|
scope: str = Body(..., embed=True),
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Update the scope of a DataSource or FeatureDataSource. Global scope requires sysAdmin."""
|
||||||
|
if scope not in _VALID_SCOPES:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid scope: {scope}. Must be one of {_VALID_SCOPES}")
|
||||||
|
|
||||||
|
if scope == "global" and not _hasSysAdminRole(context.user):
|
||||||
|
raise HTTPException(status_code=403, detail="Only sysadmins can set global scope")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
|
rootIf = getRootInterface()
|
||||||
|
rec, model = _findSourceRecord(rootIf.db, sourceId)
|
||||||
|
if not rec:
|
||||||
|
raise HTTPException(status_code=404, detail=f"DataSource {sourceId} not found")
|
||||||
|
|
||||||
|
rootIf.db.recordModify(model, sourceId, {"scope": scope})
|
||||||
|
logger.info("Updated scope=%s for %s %s", scope, model.__name__, sourceId)
|
||||||
|
return {"sourceId": sourceId, "scope": scope, "updated": True}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error updating datasource scope: %s", e)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{sourceId}/neutralize")
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
def _updateDataSourceNeutralize(
|
||||||
|
request: Request,
|
||||||
|
sourceId: str = Path(..., description="ID of the DataSource or FeatureDataSource"),
|
||||||
|
neutralize: bool = Body(..., embed=True),
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Toggle the neutralization flag on a DataSource or FeatureDataSource."""
|
||||||
|
try:
|
||||||
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
|
rootIf = getRootInterface()
|
||||||
|
rec, model = _findSourceRecord(rootIf.db, sourceId)
|
||||||
|
if not rec:
|
||||||
|
raise HTTPException(status_code=404, detail=f"DataSource {sourceId} not found")
|
||||||
|
|
||||||
|
rootIf.db.recordModify(model, sourceId, {"neutralize": neutralize})
|
||||||
|
logger.info("Updated neutralize=%s for %s %s", neutralize, model.__name__, sourceId)
|
||||||
|
return {"sourceId": sourceId, "neutralize": neutralize, "updated": True}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error updating datasource neutralize: %s", e)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
@ -639,14 +639,17 @@ def create_user(
|
||||||
|
|
||||||
# MULTI-TENANT: Add user to current mandate via UserMandate with default "user" role
|
# MULTI-TENANT: Add user to current mandate via UserMandate with default "user" role
|
||||||
if context.mandateId:
|
if context.mandateId:
|
||||||
# Get "user" role ID
|
|
||||||
userRole = appInterface.getRoleByLabel("user")
|
userRole = appInterface.getRoleByLabel("user")
|
||||||
roleIds = [str(userRole.id)] if userRole else []
|
if not userRole:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="No 'user' role found in system — cannot assign user to mandate"
|
||||||
|
)
|
||||||
|
|
||||||
appInterface.createUserMandate(
|
appInterface.createUserMandate(
|
||||||
userId=str(newUser.id),
|
userId=str(newUser.id),
|
||||||
mandateId=str(context.mandateId),
|
mandateId=str(context.mandateId),
|
||||||
roleIds=roleIds
|
roleIds=[str(userRole.id)]
|
||||||
)
|
)
|
||||||
logger.info(f"Created UserMandate for user {newUser.id} in mandate {context.mandateId}")
|
logger.info(f"Created UserMandate for user {newUser.id} in mandate {context.mandateId}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue