unified data - step 1
This commit is contained in:
parent
f796ae3807
commit
8b161ed410
27 changed files with 1764 additions and 416 deletions
|
|
@ -30,6 +30,21 @@ class DataSource(BaseModel):
|
||||||
autoSync: bool = Field(default=False, description="Automatically sync on schedule")
|
autoSync: bool = Field(default=False, description="Automatically sync on schedule")
|
||||||
lastSynced: Optional[float] = Field(default=None, description="Last sync timestamp")
|
lastSynced: Optional[float] = Field(default=None, description="Last sync timestamp")
|
||||||
createdAt: float = Field(default_factory=getUtcTimestamp, description="Creation timestamp")
|
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(
|
||||||
|
|
@ -48,6 +63,8 @@ registerModelLabels(
|
||||||
"autoSync": {"en": "Auto Sync", "de": "Auto-Sync", "fr": "Synchro auto"},
|
"autoSync": {"en": "Auto Sync", "de": "Auto-Sync", "fr": "Synchro auto"},
|
||||||
"lastSynced": {"en": "Last Synced", "de": "Letzter Sync", "fr": "Dernier sync"},
|
"lastSynced": {"en": "Last Synced", "de": "Letzter Sync", "fr": "Dernier sync"},
|
||||||
"createdAt": {"en": "Created At", "de": "Erstellt am", "fr": "Créé le"},
|
"createdAt": {"en": "Created At", "de": "Erstellt am", "fr": "Créé le"},
|
||||||
|
"scope": {"en": "Scope", "de": "Sichtbarkeit"},
|
||||||
|
"neutralize": {"en": "Neutralize", "de": "Neutralisieren"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,21 @@ class FileItem(BaseModel):
|
||||||
folderId: Optional[str] = Field(default=None, description="ID of the parent folder", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
|
folderId: Optional[str] = Field(default=None, description="ID of the parent folder", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
|
||||||
description: Optional[str] = Field(default=None, description="User-provided description of the file", json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False})
|
description: Optional[str] = Field(default=None, description="User-provided description of the file", json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False})
|
||||||
status: Optional[str] = Field(default=None, description="Processing status: pending, extracted, embedding, indexed, failed", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
status: Optional[str] = Field(default=None, description="Processing status: pending, extracted, embedding, indexed, failed", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||||
|
scope: str = Field(
|
||||||
|
default="personal",
|
||||||
|
description="Data visibility scope: personal, featureInstance, mandate, global",
|
||||||
|
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
|
||||||
|
{"value": "personal", "label": {"en": "Personal", "de": "Persönlich"}},
|
||||||
|
{"value": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}},
|
||||||
|
{"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}},
|
||||||
|
{"value": "global", "label": {"en": "Global", "de": "Global"}},
|
||||||
|
]}
|
||||||
|
)
|
||||||
|
neutralize: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Whether this file should be neutralized before AI processing",
|
||||||
|
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
|
||||||
|
)
|
||||||
|
|
||||||
registerModelLabels(
|
registerModelLabels(
|
||||||
"FileItem",
|
"FileItem",
|
||||||
|
|
@ -41,6 +56,8 @@ registerModelLabels(
|
||||||
"folderId": {"en": "Folder ID", "fr": "ID du dossier"},
|
"folderId": {"en": "Folder ID", "fr": "ID du dossier"},
|
||||||
"description": {"en": "Description", "fr": "Description"},
|
"description": {"en": "Description", "fr": "Description"},
|
||||||
"status": {"en": "Status", "fr": "Statut"},
|
"status": {"en": "Status", "fr": "Statut"},
|
||||||
|
"scope": {"en": "Scope", "de": "Sichtbarkeit"},
|
||||||
|
"neutralize": {"en": "Neutralize", "de": "Neutralisieren"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,14 @@ class FileContentIndex(BaseModel):
|
||||||
objectSummary: List[Dict[str, Any]] = Field(default_factory=list, description="Compact summary per content object")
|
objectSummary: List[Dict[str, Any]] = Field(default_factory=list, description="Compact summary per content object")
|
||||||
extractedAt: float = Field(default_factory=getUtcTimestamp, description="Extraction timestamp")
|
extractedAt: float = Field(default_factory=getUtcTimestamp, description="Extraction timestamp")
|
||||||
status: str = Field(default="pending", description="Processing status: pending, extracted, embedding, indexed, failed")
|
status: str = Field(default="pending", description="Processing status: pending, extracted, embedding, indexed, failed")
|
||||||
|
scope: str = Field(
|
||||||
|
default="personal",
|
||||||
|
description="Data visibility scope: personal, featureInstance, mandate, global",
|
||||||
|
)
|
||||||
|
neutralizationStatus: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Neutralization status: completed, failed, skipped, None = not required",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
registerModelLabels(
|
registerModelLabels(
|
||||||
|
|
@ -54,6 +62,8 @@ registerModelLabels(
|
||||||
"objectSummary": {"en": "Object Summary", "fr": "Résumé des objets"},
|
"objectSummary": {"en": "Object Summary", "fr": "Résumé des objets"},
|
||||||
"extractedAt": {"en": "Extracted At", "fr": "Extrait le"},
|
"extractedAt": {"en": "Extracted At", "fr": "Extrait le"},
|
||||||
"status": {"en": "Status", "fr": "Statut"},
|
"status": {"en": "Status", "fr": "Statut"},
|
||||||
|
"scope": {"en": "Scope", "de": "Sichtbarkeit"},
|
||||||
|
"neutralizationStatus": {"en": "Neutralization Status", "de": "Neutralisierungsstatus"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ class SubscriptionPlan(BaseModel):
|
||||||
maxUsers: Optional[int] = Field(None, description="Hard cap on active users (None = unlimited)")
|
maxUsers: Optional[int] = Field(None, description="Hard cap on active users (None = unlimited)")
|
||||||
maxFeatureInstances: Optional[int] = Field(None, description="Hard cap on active feature instances (None = unlimited)")
|
maxFeatureInstances: Optional[int] = Field(None, description="Hard cap on active feature instances (None = unlimited)")
|
||||||
trialDays: Optional[int] = Field(None, description="Trial duration in days (only for trial plans)")
|
trialDays: Optional[int] = Field(None, description="Trial duration in days (only for trial plans)")
|
||||||
|
maxDataVolumeMB: Optional[int] = Field(None, description="Soft-limit for data volume in MB per mandate (None = unlimited)")
|
||||||
successorPlanKey: Optional[str] = Field(None, description="Plan to transition to when trial ends")
|
successorPlanKey: Optional[str] = Field(None, description="Plan to transition to when trial ends")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -84,6 +85,7 @@ registerModelLabels(
|
||||||
"pricePerFeatureInstanceCHF": {"en": "Price per Instance (CHF)", "de": "Preis pro Instanz (CHF)"},
|
"pricePerFeatureInstanceCHF": {"en": "Price per Instance (CHF)", "de": "Preis pro Instanz (CHF)"},
|
||||||
"maxUsers": {"en": "Max Users", "de": "Max. Benutzer", "fr": "Max. utilisateurs"},
|
"maxUsers": {"en": "Max Users", "de": "Max. Benutzer", "fr": "Max. utilisateurs"},
|
||||||
"maxFeatureInstances": {"en": "Max Instances", "de": "Max. Instanzen", "fr": "Max. instances"},
|
"maxFeatureInstances": {"en": "Max Instances", "de": "Max. Instanzen", "fr": "Max. instances"},
|
||||||
|
"maxDataVolumeMB": {"en": "Data Volume (MB)", "de": "Datenvolumen (MB)"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -182,6 +184,7 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
|
||||||
autoRenew=False,
|
autoRenew=False,
|
||||||
maxUsers=None,
|
maxUsers=None,
|
||||||
maxFeatureInstances=None,
|
maxFeatureInstances=None,
|
||||||
|
maxDataVolumeMB=None,
|
||||||
),
|
),
|
||||||
"TRIAL_7D": SubscriptionPlan(
|
"TRIAL_7D": SubscriptionPlan(
|
||||||
planKey="TRIAL_7D",
|
planKey="TRIAL_7D",
|
||||||
|
|
@ -196,6 +199,7 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
|
||||||
maxUsers=1,
|
maxUsers=1,
|
||||||
maxFeatureInstances=3,
|
maxFeatureInstances=3,
|
||||||
trialDays=7,
|
trialDays=7,
|
||||||
|
maxDataVolumeMB=500,
|
||||||
successorPlanKey="STANDARD_MONTHLY",
|
successorPlanKey="STANDARD_MONTHLY",
|
||||||
),
|
),
|
||||||
"STANDARD_MONTHLY": SubscriptionPlan(
|
"STANDARD_MONTHLY": SubscriptionPlan(
|
||||||
|
|
@ -209,6 +213,7 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
|
||||||
billingPeriod=BillingPeriodEnum.MONTHLY,
|
billingPeriod=BillingPeriodEnum.MONTHLY,
|
||||||
pricePerUserCHF=90.0,
|
pricePerUserCHF=90.0,
|
||||||
pricePerFeatureInstanceCHF=150.0,
|
pricePerFeatureInstanceCHF=150.0,
|
||||||
|
maxDataVolumeMB=10240,
|
||||||
),
|
),
|
||||||
"STANDARD_YEARLY": SubscriptionPlan(
|
"STANDARD_YEARLY": SubscriptionPlan(
|
||||||
planKey="STANDARD_YEARLY",
|
planKey="STANDARD_YEARLY",
|
||||||
|
|
@ -221,6 +226,7 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
|
||||||
billingPeriod=BillingPeriodEnum.YEARLY,
|
billingPeriod=BillingPeriodEnum.YEARLY,
|
||||||
pricePerUserCHF=1080.0,
|
pricePerUserCHF=1080.0,
|
||||||
pricePerFeatureInstanceCHF=1800.0,
|
pricePerFeatureInstanceCHF=1800.0,
|
||||||
|
maxDataVolumeMB=10240,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ Multi-Tenant Design:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Optional, List
|
from typing import Optional, List, Dict
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pydantic import BaseModel, Field, EmailStr, field_validator, computed_field
|
from pydantic import BaseModel, Field, EmailStr, field_validator, computed_field
|
||||||
from modules.shared.attributeUtils import registerModelLabels
|
from modules.shared.attributeUtils import registerModelLabels
|
||||||
|
|
@ -59,6 +59,12 @@ class UserPermissions(BaseModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MandateType(str, Enum):
|
||||||
|
SYSTEM = "system"
|
||||||
|
PERSONAL = "personal"
|
||||||
|
COMPANY = "company"
|
||||||
|
|
||||||
|
|
||||||
class Mandate(BaseModel):
|
class Mandate(BaseModel):
|
||||||
"""
|
"""
|
||||||
Mandate (Mandant/Tenant) model.
|
Mandate (Mandant/Tenant) model.
|
||||||
|
|
@ -88,6 +94,15 @@ class Mandate(BaseModel):
|
||||||
description="Whether this is a system mandate (e.g. root mandate). Cannot be deleted.",
|
description="Whether this is a system mandate (e.g. root mandate). Cannot be deleted.",
|
||||||
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False}
|
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False}
|
||||||
)
|
)
|
||||||
|
mandateType: MandateType = Field(
|
||||||
|
default=MandateType.COMPANY,
|
||||||
|
description="Fachlicher Mandantentyp: system (Root), personal (Solo), company (Team). Mutabel, rein informativ — keine Feature-Gates.",
|
||||||
|
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
|
||||||
|
{"value": "system", "label": {"en": "System", "de": "System"}},
|
||||||
|
{"value": "personal", "label": {"en": "Personal", "de": "Persönlich"}},
|
||||||
|
{"value": "company", "label": {"en": "Company", "de": "Unternehmen"}},
|
||||||
|
]}
|
||||||
|
)
|
||||||
|
|
||||||
@field_validator('isSystem', mode='before')
|
@field_validator('isSystem', mode='before')
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -97,6 +112,18 @@ class Mandate(BaseModel):
|
||||||
return False
|
return False
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
@field_validator('mandateType', mode='before')
|
||||||
|
@classmethod
|
||||||
|
def _coerceMandateType(cls, v):
|
||||||
|
if v is None:
|
||||||
|
return MandateType.COMPANY
|
||||||
|
if isinstance(v, str):
|
||||||
|
try:
|
||||||
|
return MandateType(v)
|
||||||
|
except ValueError:
|
||||||
|
return MandateType.COMPANY
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
registerModelLabels(
|
registerModelLabels(
|
||||||
"Mandate",
|
"Mandate",
|
||||||
|
|
@ -107,6 +134,7 @@ registerModelLabels(
|
||||||
"label": {"en": "Label", "de": "Label", "fr": "Libellé"},
|
"label": {"en": "Label", "de": "Label", "fr": "Libellé"},
|
||||||
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
|
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
|
||||||
"isSystem": {"en": "System Mandate", "de": "System-Mandant", "fr": "Mandat système"},
|
"isSystem": {"en": "System Mandate", "de": "System-Mandant", "fr": "Mandat système"},
|
||||||
|
"mandateType": {"en": "Mandate Type", "de": "Mandantentyp", "fr": "Type de mandat"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -289,3 +317,33 @@ registerModelLabels(
|
||||||
"resetTokenExpires": {"en": "Reset Token Expires", "de": "Token läuft ab", "fr": "Expiration du jeton"},
|
"resetTokenExpires": {"en": "Reset Token Expires", "de": "Token läuft ab", "fr": "Expiration du jeton"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UserVoicePreferences(BaseModel):
|
||||||
|
"""User-level voice/language preferences, shared across all features."""
|
||||||
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
|
||||||
|
userId: str = Field(description="User ID")
|
||||||
|
mandateId: Optional[str] = Field(default=None, description="Mandate scope (None = global for user)")
|
||||||
|
sttLanguage: str = Field(default="de-DE", description="Speech-to-text language code")
|
||||||
|
ttsLanguage: str = Field(default="de-DE", description="Text-to-speech language code")
|
||||||
|
ttsVoice: Optional[str] = Field(default=None, description="Preferred TTS voice identifier")
|
||||||
|
ttsVoiceMap: Optional[Dict[str, str]] = Field(default=None, description="Language-to-voice mapping")
|
||||||
|
translationSourceLanguage: Optional[str] = Field(default=None, description="Source language for translations")
|
||||||
|
translationTargetLanguage: Optional[str] = Field(default=None, description="Target language for translations")
|
||||||
|
|
||||||
|
|
||||||
|
registerModelLabels(
|
||||||
|
"UserVoicePreferences",
|
||||||
|
{"en": "Voice Preferences", "de": "Spracheinstellungen", "fr": "Préférences vocales"},
|
||||||
|
{
|
||||||
|
"id": {"en": "ID", "de": "ID", "fr": "ID"},
|
||||||
|
"userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
|
||||||
|
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID", "fr": "ID du mandat"},
|
||||||
|
"sttLanguage": {"en": "STT Language", "de": "STT-Sprache", "fr": "Langue STT"},
|
||||||
|
"ttsLanguage": {"en": "TTS Language", "de": "TTS-Sprache", "fr": "Langue TTS"},
|
||||||
|
"ttsVoice": {"en": "TTS Voice", "de": "TTS-Stimme", "fr": "Voix TTS"},
|
||||||
|
"ttsVoiceMap": {"en": "Voice Map", "de": "Stimmen-Zuordnung", "fr": "Carte des voix"},
|
||||||
|
"translationSourceLanguage": {"en": "Translation Source", "de": "Übersetzung Quelle", "fr": "Langue source"},
|
||||||
|
"translationTargetLanguage": {"en": "Translation Target", "de": "Übersetzung Ziel", "fr": "Langue cible"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""Voice settings datamodel — re-exported from workspace feature for backward compatibility."""
|
"""Voice settings datamodel — re-exported from UAM for central voice preferences."""
|
||||||
|
|
||||||
from modules.features.workspace.datamodelFeatureWorkspace import VoiceSettings
|
from modules.datamodels.datamodelUam import UserVoicePreferences
|
||||||
|
|
||||||
__all__ = ["VoiceSettings"]
|
__all__ = ["UserVoicePreferences"]
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,32 @@
|
||||||
"""Neutralizer models: DataNeutraliserConfig and DataNeutralizerAttributes."""
|
"""Neutralizer models: DataNeutraliserConfig and DataNeutralizerAttributes."""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from enum import Enum
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from modules.shared.attributeUtils import registerModelLabels
|
from modules.shared.attributeUtils import registerModelLabels
|
||||||
|
|
||||||
|
|
||||||
|
class DataScope(str, Enum):
|
||||||
|
PERSONAL = "personal"
|
||||||
|
FEATURE_INSTANCE = "featureInstance"
|
||||||
|
MANDATE = "mandate"
|
||||||
|
GLOBAL = "global"
|
||||||
|
|
||||||
|
|
||||||
class DataNeutraliserConfig(BaseModel):
|
class DataNeutraliserConfig(BaseModel):
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the configuration", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the configuration", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||||
mandateId: str = Field(description="ID of the mandate this configuration belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
mandateId: str = Field(description="ID of the mandate this configuration belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
||||||
featureInstanceId: str = Field(description="ID of the feature instance this configuration belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
featureInstanceId: str = Field(description="ID of the feature instance this configuration belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
||||||
userId: str = Field(description="ID of the user who created this configuration", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
userId: str = Field(description="ID of the user who created this configuration", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
||||||
enabled: bool = Field(default=True, description="Whether data neutralization is enabled", json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False})
|
enabled: bool = Field(default=True, description="Whether data neutralization is enabled", json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False})
|
||||||
|
scope: str = Field(default="personal", description="Data visibility scope: personal, featureInstance, mandate, global", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
|
||||||
|
{"value": "personal", "label": {"en": "Personal", "de": "Persönlich"}},
|
||||||
|
{"value": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}},
|
||||||
|
{"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}},
|
||||||
|
{"value": "global", "label": {"en": "Global", "de": "Global"}},
|
||||||
|
]})
|
||||||
|
neutralizationStatus: str = Field(default="not_required", description="Status of neutralization: pending, completed, failed, not_required", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||||
namesToParse: str = Field(default="", description="Multiline list of names to parse for neutralization", json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False})
|
namesToParse: str = Field(default="", description="Multiline list of names to parse for neutralization", json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False})
|
||||||
sharepointSourcePath: str = Field(default="", description="SharePoint path to read files for neutralization", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
|
sharepointSourcePath: str = Field(default="", description="SharePoint path to read files for neutralization", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
|
||||||
sharepointTargetPath: str = Field(default="", description="SharePoint path to store neutralized files", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
|
sharepointTargetPath: str = Field(default="", description="SharePoint path to store neutralized files", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
|
||||||
|
|
@ -26,6 +41,8 @@ registerModelLabels(
|
||||||
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
|
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
|
||||||
"userId": {"en": "User ID", "fr": "ID utilisateur"},
|
"userId": {"en": "User ID", "fr": "ID utilisateur"},
|
||||||
"enabled": {"en": "Enabled", "fr": "Activé"},
|
"enabled": {"en": "Enabled", "fr": "Activé"},
|
||||||
|
"scope": {"en": "Scope", "fr": "Portée"},
|
||||||
|
"neutralizationStatus": {"en": "Neutralization Status", "fr": "Statut de neutralisation"},
|
||||||
"namesToParse": {"en": "Names to Parse", "fr": "Noms à analyser"},
|
"namesToParse": {"en": "Names to Parse", "fr": "Noms à analyser"},
|
||||||
"sharepointSourcePath": {"en": "Source Path", "fr": "Chemin source"},
|
"sharepointSourcePath": {"en": "Source Path", "fr": "Chemin source"},
|
||||||
"sharepointTargetPath": {"en": "Target Path", "fr": "Chemin cible"},
|
"sharepointTargetPath": {"en": "Target Path", "fr": "Chemin cible"},
|
||||||
|
|
|
||||||
|
|
@ -212,6 +212,21 @@ class InterfaceFeatureNeutralizer:
|
||||||
logger.error(f"Error getting attribute by ID: {str(e)}")
|
logger.error(f"Error getting attribute by ID: {str(e)}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def deleteAttributeById(self, attributeId: str) -> bool:
|
||||||
|
"""Delete a single neutralization attribute by its ID"""
|
||||||
|
try:
|
||||||
|
attribute = self.getAttributeById(attributeId)
|
||||||
|
if not attribute:
|
||||||
|
logger.warning(f"Attribute {attributeId} not found for deletion")
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.db.recordDelete(DataNeutralizerAttributes, attributeId)
|
||||||
|
logger.info(f"Deleted neutralization attribute {attributeId}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting attribute by ID: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
def createAttribute(
|
def createAttribute(
|
||||||
self,
|
self,
|
||||||
attributeId: str,
|
attributeId: str,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from urllib.parse import urlparse, unquote
|
||||||
|
|
||||||
from modules.datamodels.datamodelUam import User
|
from modules.datamodels.datamodelUam import User
|
||||||
from .datamodelFeatureNeutralizer import DataNeutralizerAttributes, DataNeutraliserConfig
|
from .datamodelFeatureNeutralizer import DataNeutralizerAttributes, DataNeutraliserConfig
|
||||||
|
from .interfaceFeatureNeutralizer import getInterface as _getNeutralizerInterface
|
||||||
from modules.serviceHub import getInterface as getServices
|
from modules.serviceHub import getInterface as getServices
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -129,6 +130,11 @@ class NeutralizationPlayground:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Delete a single attribute by ID
|
||||||
|
def deleteAttribute(self, attributeId: str) -> bool:
|
||||||
|
interface = _getNeutralizerInterface(self.currentUser, self.mandateId, self.featureInstanceId)
|
||||||
|
return interface.deleteAttributeById(attributeId)
|
||||||
|
|
||||||
# Cleanup attributes
|
# Cleanup attributes
|
||||||
def cleanAttributes(self, fileId: str) -> bool:
|
def cleanAttributes(self, fileId: str) -> bool:
|
||||||
return self.services.neutralization.deleteNeutralizationAttributes(fileId)
|
return self.services.neutralization.deleteNeutralizationAttributes(fileId)
|
||||||
|
|
|
||||||
|
|
@ -317,6 +317,66 @@ def get_neutralization_stats(
|
||||||
detail=f"Error getting neutralization stats: {str(e)}"
|
detail=f"Error getting neutralization stats: {str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@router.delete("/attributes/single/{attributeId}", response_model=Dict[str, str])
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
def deleteAttribute(
|
||||||
|
request: Request,
|
||||||
|
attributeId: str = Path(..., description="Attribute ID to delete"),
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
) -> Dict[str, str]:
|
||||||
|
"""Delete a single neutralization attribute by ID."""
|
||||||
|
try:
|
||||||
|
service = NeutralizationPlayground(
|
||||||
|
context.user,
|
||||||
|
str(context.mandateId) if context.mandateId else "",
|
||||||
|
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
|
||||||
|
)
|
||||||
|
success = service.deleteAttribute(attributeId)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return {"message": f"Attribute {attributeId} deleted"}
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Attribute {attributeId} not found"
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting attribute: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/retrigger", response_model=Dict[str, str])
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
def retriggerNeutralization(
|
||||||
|
request: Request,
|
||||||
|
retriggerData: Dict[str, str] = Body(...),
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
) -> Dict[str, str]:
|
||||||
|
"""Re-trigger neutralization for a specific file."""
|
||||||
|
try:
|
||||||
|
fileId = retriggerData.get("fileId", "")
|
||||||
|
if not fileId:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="fileId is required"
|
||||||
|
)
|
||||||
|
|
||||||
|
service = NeutralizationPlayground(
|
||||||
|
context.user,
|
||||||
|
str(context.mandateId) if context.mandateId else "",
|
||||||
|
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
|
||||||
|
)
|
||||||
|
service.cleanupFileAttributes(fileId)
|
||||||
|
return {"message": f"Neutralization re-triggered for file {fileId}", "fileId": fileId}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error re-triggering neutralization: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/attributes/{fileId}", response_model=Dict[str, str])
|
@router.delete("/attributes/{fileId}", response_model=Dict[str, str])
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
def cleanup_file_attributes(
|
def cleanup_file_attributes(
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,13 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""Workspace feature data models — VoiceSettings and WorkspaceUserSettings."""
|
"""Workspace feature data models — WorkspaceUserSettings."""
|
||||||
|
|
||||||
from typing import Dict, Any, Optional
|
from typing import Optional
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from modules.shared.attributeUtils import registerModelLabels
|
from modules.shared.attributeUtils import registerModelLabels
|
||||||
from modules.shared.timeUtils import getUtcTimestamp
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
class VoiceSettings(BaseModel):
|
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
|
||||||
userId: str = Field(description="ID of the user these settings belong to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
|
||||||
mandateId: str = Field(description="ID of the mandate these settings belong to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
|
||||||
featureInstanceId: str = Field(description="ID of the feature instance these settings belong to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
|
|
||||||
sttLanguage: str = Field(default="de-DE", description="Speech-to-Text language", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True})
|
|
||||||
ttsLanguage: str = Field(default="de-DE", description="Text-to-Speech language", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True})
|
|
||||||
ttsVoice: str = Field(default="de-DE-KatjaNeural", description="Text-to-Speech voice", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True})
|
|
||||||
ttsVoiceMap: Dict[str, Any] = Field(default_factory=dict, description="Per-language voice mapping, e.g. {'de-DE': {'voiceName': 'de-DE-Wavenet-A'}, 'en-US': {'voiceName': 'en-US-Wavenet-C'}}", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False})
|
|
||||||
translationEnabled: bool = Field(default=True, description="Whether translation is enabled", json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False})
|
|
||||||
targetLanguage: str = Field(default="en-US", description="Target language for translation", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False})
|
|
||||||
creationDate: float = Field(default_factory=getUtcTimestamp, description="Date when the settings were created (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
|
|
||||||
lastModified: float = Field(default_factory=getUtcTimestamp, description="Date when the settings were last modified (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
|
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceUserSettings(BaseModel):
|
class WorkspaceUserSettings(BaseModel):
|
||||||
"""Per-user workspace settings. None values mean 'use instance default'."""
|
"""Per-user workspace settings. None values mean 'use instance default'."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||||
|
|
@ -33,25 +17,6 @@ class WorkspaceUserSettings(BaseModel):
|
||||||
maxAgentRounds: Optional[int] = Field(default=None, description="Max agent rounds override (None = instance default)", json_schema_extra={"frontend_type": "number", "frontend_readonly": False, "frontend_required": False})
|
maxAgentRounds: Optional[int] = Field(default=None, description="Max agent rounds override (None = instance default)", json_schema_extra={"frontend_type": "number", "frontend_readonly": False, "frontend_required": False})
|
||||||
|
|
||||||
|
|
||||||
registerModelLabels(
|
|
||||||
"VoiceSettings",
|
|
||||||
{"en": "Voice Settings", "fr": "Paramètres vocaux"},
|
|
||||||
{
|
|
||||||
"id": {"en": "ID", "fr": "ID"},
|
|
||||||
"userId": {"en": "User ID", "fr": "ID utilisateur"},
|
|
||||||
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
|
|
||||||
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
|
|
||||||
"sttLanguage": {"en": "STT Language", "fr": "Langue STT"},
|
|
||||||
"ttsLanguage": {"en": "TTS Language", "fr": "Langue TTS"},
|
|
||||||
"ttsVoice": {"en": "TTS Voice", "fr": "Voix TTS"},
|
|
||||||
"ttsVoiceMap": {"en": "TTS Voice Map", "fr": "Carte des voix TTS"},
|
|
||||||
"translationEnabled": {"en": "Translation Enabled", "fr": "Traduction activée"},
|
|
||||||
"targetLanguage": {"en": "Target Language", "fr": "Langue cible"},
|
|
||||||
"creationDate": {"en": "Creation Date", "fr": "Date de création"},
|
|
||||||
"lastModified": {"en": "Last Modified", "fr": "Dernière modification"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
registerModelLabels(
|
registerModelLabels(
|
||||||
"WorkspaceUserSettings",
|
"WorkspaceUserSettings",
|
||||||
{"en": "Workspace User Settings", "de": "Workspace Benutzereinstellungen"},
|
{"en": "Workspace User Settings", "de": "Workspace Benutzereinstellungen"},
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from typing import Dict, Any, Optional
|
||||||
|
|
||||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||||
from modules.datamodels.datamodelUam import User
|
from modules.datamodels.datamodelUam import User
|
||||||
from modules.features.workspace.datamodelFeatureWorkspace import VoiceSettings, WorkspaceUserSettings
|
from modules.features.workspace.datamodelFeatureWorkspace import WorkspaceUserSettings
|
||||||
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
|
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
|
||||||
from modules.security.rbac import RbacClass
|
from modules.security.rbac import RbacClass
|
||||||
from modules.shared.configuration import APP_CONFIG
|
from modules.shared.configuration import APP_CONFIG
|
||||||
|
|
@ -62,122 +62,6 @@ class WorkspaceObjects:
|
||||||
self.featureInstanceId = featureInstanceId
|
self.featureInstanceId = featureInstanceId
|
||||||
self.db.updateContext(self.userId)
|
self.db.updateContext(self.userId)
|
||||||
|
|
||||||
# =========================================================================
|
|
||||||
# VoiceSettings CRUD
|
|
||||||
# =========================================================================
|
|
||||||
|
|
||||||
def getVoiceSettings(self, userId: Optional[str] = None) -> Optional[VoiceSettings]:
|
|
||||||
try:
|
|
||||||
targetUserId = userId or self.userId
|
|
||||||
if not targetUserId:
|
|
||||||
logger.error("No user ID provided for voice settings")
|
|
||||||
return None
|
|
||||||
|
|
||||||
recordFilter: Dict[str, Any] = {"userId": targetUserId}
|
|
||||||
if self.featureInstanceId:
|
|
||||||
recordFilter["featureInstanceId"] = self.featureInstanceId
|
|
||||||
|
|
||||||
filteredSettings = getRecordsetWithRBAC(
|
|
||||||
self.db, VoiceSettings, self.currentUser,
|
|
||||||
recordFilter=recordFilter, mandateId=self.mandateId,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not filteredSettings:
|
|
||||||
return None
|
|
||||||
|
|
||||||
settingsData = filteredSettings[0]
|
|
||||||
if not settingsData.get("creationDate"):
|
|
||||||
settingsData["creationDate"] = getUtcTimestamp()
|
|
||||||
if not settingsData.get("lastModified"):
|
|
||||||
settingsData["lastModified"] = getUtcTimestamp()
|
|
||||||
|
|
||||||
return VoiceSettings(**settingsData)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting voice settings: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def createVoiceSettings(self, settingsData: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
try:
|
|
||||||
if "userId" not in settingsData:
|
|
||||||
settingsData["userId"] = self.userId
|
|
||||||
if "mandateId" not in settingsData:
|
|
||||||
settingsData["mandateId"] = self.mandateId
|
|
||||||
if "featureInstanceId" not in settingsData:
|
|
||||||
settingsData["featureInstanceId"] = self.featureInstanceId
|
|
||||||
|
|
||||||
existing = self.getVoiceSettings(settingsData["userId"])
|
|
||||||
if existing:
|
|
||||||
raise ValueError(f"Voice settings already exist for user {settingsData['userId']}")
|
|
||||||
|
|
||||||
createdRecord = self.db.recordCreate(VoiceSettings, settingsData)
|
|
||||||
if not createdRecord or not createdRecord.get("id"):
|
|
||||||
raise ValueError("Failed to create voice settings record")
|
|
||||||
|
|
||||||
logger.info(f"Created voice settings for user {settingsData['userId']}")
|
|
||||||
return createdRecord
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error creating voice settings: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
def updateVoiceSettings(self, userId: str, updateData: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
try:
|
|
||||||
existing = self.getVoiceSettings(userId)
|
|
||||||
if not existing:
|
|
||||||
raise ValueError(f"Voice settings not found for user {userId}")
|
|
||||||
|
|
||||||
updateData["lastModified"] = getUtcTimestamp()
|
|
||||||
success = self.db.recordModify(VoiceSettings, existing.id, updateData)
|
|
||||||
if not success:
|
|
||||||
raise ValueError("Failed to update voice settings record")
|
|
||||||
|
|
||||||
updated = self.getVoiceSettings(userId)
|
|
||||||
if not updated:
|
|
||||||
raise ValueError("Failed to retrieve updated voice settings")
|
|
||||||
|
|
||||||
logger.info(f"Updated voice settings for user {userId}")
|
|
||||||
return updated.model_dump()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error updating voice settings: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
def deleteVoiceSettings(self, userId: str) -> bool:
|
|
||||||
try:
|
|
||||||
existing = self.getVoiceSettings(userId)
|
|
||||||
if not existing:
|
|
||||||
return False
|
|
||||||
success = self.db.recordDelete(VoiceSettings, existing.id)
|
|
||||||
if success:
|
|
||||||
logger.info(f"Deleted voice settings for user {userId}")
|
|
||||||
return success
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error deleting voice settings: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def getOrCreateVoiceSettings(self, userId: Optional[str] = None) -> VoiceSettings:
|
|
||||||
targetUserId = userId or self.userId
|
|
||||||
if not targetUserId:
|
|
||||||
raise ValueError("No user ID provided for voice settings")
|
|
||||||
|
|
||||||
existing = self.getVoiceSettings(targetUserId)
|
|
||||||
if existing:
|
|
||||||
return existing
|
|
||||||
|
|
||||||
defaultSettings = {
|
|
||||||
"userId": targetUserId,
|
|
||||||
"mandateId": self.mandateId,
|
|
||||||
"featureInstanceId": self.featureInstanceId,
|
|
||||||
"sttLanguage": "de-DE",
|
|
||||||
"ttsLanguage": "de-DE",
|
|
||||||
"ttsVoice": "de-DE-KatjaNeural",
|
|
||||||
"translationEnabled": True,
|
|
||||||
"targetLanguage": "en-US",
|
|
||||||
}
|
|
||||||
createdRecord = self.createVoiceSettings(defaultSettings)
|
|
||||||
return VoiceSettings(**createdRecord)
|
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# WorkspaceUserSettings CRUD
|
# WorkspaceUserSettings CRUD
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -92,8 +92,24 @@ def initBootstrap(db: DatabaseConnector) -> None:
|
||||||
# Seed automation templates (after admin user exists)
|
# Seed automation templates (after admin user exists)
|
||||||
initAutomationTemplates(db, adminUserId)
|
initAutomationTemplates(db, adminUserId)
|
||||||
|
|
||||||
# Initialize feature instances for root mandate
|
# Run root-user migration (one-time, sets completion flag)
|
||||||
if mandateId:
|
migrationDone = False
|
||||||
|
try:
|
||||||
|
from modules.migration.migrateRootUsers import migrateRootUsers, _isMigrationCompleted
|
||||||
|
migrationDone = _isMigrationCompleted(db)
|
||||||
|
if not migrationDone:
|
||||||
|
# Create root instances first (needed for migration), then migrate
|
||||||
|
if mandateId:
|
||||||
|
initRootMandateFeatures(db, mandateId)
|
||||||
|
result = migrateRootUsers(db)
|
||||||
|
migrationDone = result.get("status") != "error"
|
||||||
|
else:
|
||||||
|
migrationDone = True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Root user migration failed: {e}")
|
||||||
|
|
||||||
|
# After migration: root mandate is purely technical — no feature instances
|
||||||
|
if not migrationDone and mandateId:
|
||||||
initRootMandateFeatures(db, mandateId)
|
initRootMandateFeatures(db, mandateId)
|
||||||
|
|
||||||
# Remove feature instances for features that no longer exist in the codebase
|
# Remove feature instances for features that no longer exist in the codebase
|
||||||
|
|
@ -310,6 +326,8 @@ def initRootMandate(db: DatabaseConnector) -> Optional[str]:
|
||||||
if existingMandates:
|
if existingMandates:
|
||||||
mandateId = existingMandates[0].get("id")
|
mandateId = existingMandates[0].get("id")
|
||||||
logger.info(f"Root mandate already exists with ID {mandateId}")
|
logger.info(f"Root mandate already exists with ID {mandateId}")
|
||||||
|
# Ensure mandateType is set to system
|
||||||
|
db.recordModify(Mandate, mandateId, {"mandateType": "system"})
|
||||||
return mandateId
|
return mandateId
|
||||||
|
|
||||||
# Check for legacy root mandates (name="Root" without isSystem flag) and migrate
|
# Check for legacy root mandates (name="Root" without isSystem flag) and migrate
|
||||||
|
|
@ -325,6 +343,8 @@ def initRootMandate(db: DatabaseConnector) -> Optional[str]:
|
||||||
createdMandate = db.recordCreate(Mandate, rootMandate)
|
createdMandate = db.recordCreate(Mandate, rootMandate)
|
||||||
mandateId = createdMandate.get("id")
|
mandateId = createdMandate.get("id")
|
||||||
logger.info(f"Root mandate created with ID {mandateId}")
|
logger.info(f"Root mandate created with ID {mandateId}")
|
||||||
|
# mandateType already set via Mandate constructor, but ensure:
|
||||||
|
db.recordModify(Mandate, mandateId, {"mandateType": "system"})
|
||||||
return mandateId
|
return mandateId
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -734,9 +734,8 @@ class AppObjects:
|
||||||
|
|
||||||
# Clear cache to ensure fresh data (already done above)
|
# Clear cache to ensure fresh data (already done above)
|
||||||
|
|
||||||
# Assign new user to the root mandate with mandate-instance 'user' role (no feature instances)
|
# Note: root mandate assignment removed — users get their own mandate via
|
||||||
userId = createdUser[0]["id"]
|
# _provisionMandateForUser during registration. Root mandate is purely technical.
|
||||||
self._assignUserToRootMandate(userId)
|
|
||||||
|
|
||||||
return User(**createdUser[0])
|
return User(**createdUser[0])
|
||||||
|
|
||||||
|
|
@ -1456,6 +1455,163 @@ class AppObjects:
|
||||||
|
|
||||||
return Mandate(**createdRecord)
|
return Mandate(**createdRecord)
|
||||||
|
|
||||||
|
def _provisionMandateForUser(self, userId: str, mandateType: str, mandateName: str, planKey: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Atomic provisioning: create Mandate + UserMandate + Subscription + auto-create FeatureInstances.
|
||||||
|
Internal method — bypasses RBAC (used during registration when user has no permissions yet).
|
||||||
|
"""
|
||||||
|
from modules.datamodels.datamodelUam import MandateType
|
||||||
|
from modules.datamodels.datamodelSubscription import MandateSubscription, SubscriptionStatusEnum, BUILTIN_PLANS
|
||||||
|
from modules.datamodels.datamodelFeatures import FeatureInstance
|
||||||
|
from modules.interfaces.interfaceBootstrap import copySystemRolesToMandate
|
||||||
|
from modules.interfaces.interfaceFeatures import getFeatureInterface
|
||||||
|
from modules.system.registry import loadFeatureMainModules
|
||||||
|
|
||||||
|
plan = BUILTIN_PLANS.get(planKey)
|
||||||
|
if not plan:
|
||||||
|
raise ValueError(f"Unknown plan: {planKey}")
|
||||||
|
|
||||||
|
mandateData = Mandate(
|
||||||
|
name=mandateName,
|
||||||
|
label=mandateName,
|
||||||
|
enabled=True,
|
||||||
|
isSystem=False,
|
||||||
|
mandateType=MandateType(mandateType),
|
||||||
|
)
|
||||||
|
createdMandate = self.db.recordCreate(Mandate, mandateData)
|
||||||
|
if not createdMandate or not createdMandate.get("id"):
|
||||||
|
raise ValueError("Failed to create mandate")
|
||||||
|
mandateId = createdMandate["id"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
copySystemRolesToMandate(self.db, mandateId)
|
||||||
|
|
||||||
|
adminRoleId = None
|
||||||
|
mandateRoles = self.db.getRecordset(Role, recordFilter={"mandateId": mandateId, "featureInstanceId": None})
|
||||||
|
for r in mandateRoles:
|
||||||
|
if "admin" in (r.get("roleLabel") or "").lower():
|
||||||
|
adminRoleId = r.get("id")
|
||||||
|
break
|
||||||
|
|
||||||
|
userMandate = UserMandate(userId=userId, mandateId=mandateId, enabled=True)
|
||||||
|
createdUm = self.db.recordCreate(UserMandate, userMandate.model_dump())
|
||||||
|
if adminRoleId and createdUm:
|
||||||
|
umRole = UserMandateRole(userMandateId=createdUm["id"], roleId=adminRoleId)
|
||||||
|
self.db.recordCreate(UserMandateRole, umRole.model_dump())
|
||||||
|
|
||||||
|
subscription = MandateSubscription(
|
||||||
|
mandateId=mandateId,
|
||||||
|
planKey=planKey,
|
||||||
|
status=SubscriptionStatusEnum.PENDING,
|
||||||
|
)
|
||||||
|
if plan.trialDays:
|
||||||
|
pass # trialEndsAt set on ACTIVE transition
|
||||||
|
self.db.recordCreate(MandateSubscription, subscription.model_dump())
|
||||||
|
|
||||||
|
featureInterface = getFeatureInterface(self.db)
|
||||||
|
mainModules = loadFeatureMainModules()
|
||||||
|
createdInstances = []
|
||||||
|
for featureName, module in mainModules.items():
|
||||||
|
if not hasattr(module, "getFeatureDefinition"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
featureDef = module.getFeatureDefinition()
|
||||||
|
if not featureDef.get("autoCreateInstance", False):
|
||||||
|
continue
|
||||||
|
featureCode = featureDef.get("code", featureName)
|
||||||
|
featureLabel = featureDef.get("label", {}).get("en", featureName)
|
||||||
|
instance = featureInterface.createFeatureInstance(
|
||||||
|
featureCode=featureCode,
|
||||||
|
mandateId=mandateId,
|
||||||
|
label=featureLabel,
|
||||||
|
enabled=True,
|
||||||
|
copyTemplateRoles=True,
|
||||||
|
)
|
||||||
|
if instance:
|
||||||
|
instanceId = instance.get("id") if isinstance(instance, dict) else instance.id
|
||||||
|
createdInstances.append(instanceId)
|
||||||
|
instanceRoles = self.db.getRecordset(Role, recordFilter={"featureInstanceId": instanceId})
|
||||||
|
adminInstRoleId = None
|
||||||
|
for ir in instanceRoles:
|
||||||
|
if "admin" in (ir.get("roleLabel") or "").lower():
|
||||||
|
adminInstRoleId = ir.get("id")
|
||||||
|
break
|
||||||
|
fa = FeatureAccess(userId=userId, featureInstanceId=instanceId, enabled=True)
|
||||||
|
createdFa = self.db.recordCreate(FeatureAccess, fa.model_dump())
|
||||||
|
if adminInstRoleId and createdFa:
|
||||||
|
far = FeatureAccessRole(featureAccessId=createdFa["id"], roleId=adminInstRoleId)
|
||||||
|
self.db.recordCreate(FeatureAccessRole, far.model_dump())
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error auto-creating instance for '{featureName}': {e}")
|
||||||
|
|
||||||
|
logger.info(f"Provisioned mandate {mandateId} (type={mandateType}, plan={planKey}) for user {userId}, instances={createdInstances}")
|
||||||
|
return {
|
||||||
|
"mandateId": mandateId,
|
||||||
|
"planKey": planKey,
|
||||||
|
"mandateType": mandateType,
|
||||||
|
"featureInstances": createdInstances,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Provisioning failed for user {userId}, cleaning up mandate {mandateId}: {e}")
|
||||||
|
try:
|
||||||
|
self.db.recordDelete(Mandate, mandateId)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise ValueError(f"Mandate provisioning failed: {e}")
|
||||||
|
|
||||||
|
def _activatePendingSubscriptions(self, userId: str) -> int:
|
||||||
|
"""
|
||||||
|
Activate PENDING subscriptions for all mandates where this user is a member.
|
||||||
|
Called on login — trial period begins NOW, not at registration.
|
||||||
|
Returns number of activated subscriptions.
|
||||||
|
"""
|
||||||
|
from modules.datamodels.datamodelSubscription import (
|
||||||
|
MandateSubscription, SubscriptionStatusEnum, BUILTIN_PLANS,
|
||||||
|
)
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
activated = 0
|
||||||
|
userMandates = self.db.getRecordset(
|
||||||
|
UserMandate, recordFilter={"userId": userId, "enabled": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
for um in userMandates:
|
||||||
|
mandateId = um.get("mandateId")
|
||||||
|
subs = self.db.getRecordset(
|
||||||
|
MandateSubscription,
|
||||||
|
recordFilter={"mandateId": mandateId, "status": SubscriptionStatusEnum.PENDING.value}
|
||||||
|
)
|
||||||
|
for sub in subs:
|
||||||
|
subId = sub.get("id")
|
||||||
|
planKey = sub.get("planKey")
|
||||||
|
plan = BUILTIN_PLANS.get(planKey)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
updateData = {
|
||||||
|
"status": SubscriptionStatusEnum.TRIALING.value if plan and plan.trialDays else SubscriptionStatusEnum.ACTIVE.value,
|
||||||
|
"currentPeriodStart": now.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if plan and plan.trialDays:
|
||||||
|
trialEnd = now + timedelta(days=plan.trialDays)
|
||||||
|
updateData["trialEndsAt"] = trialEnd.isoformat()
|
||||||
|
updateData["currentPeriodEnd"] = trialEnd.isoformat()
|
||||||
|
elif plan and plan.billingPeriod:
|
||||||
|
from modules.datamodels.datamodelSubscription import BillingPeriodEnum
|
||||||
|
if plan.billingPeriod == BillingPeriodEnum.MONTHLY:
|
||||||
|
updateData["currentPeriodEnd"] = (now + timedelta(days=30)).isoformat()
|
||||||
|
elif plan.billingPeriod == BillingPeriodEnum.YEARLY:
|
||||||
|
updateData["currentPeriodEnd"] = (now + timedelta(days=365)).isoformat()
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.db.recordModify(MandateSubscription, subId, updateData)
|
||||||
|
activated += 1
|
||||||
|
logger.info(f"Activated subscription {subId} (plan={planKey}) for mandate {mandateId}: {updateData.get('status')}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to activate subscription {subId}: {e}")
|
||||||
|
|
||||||
|
return activated
|
||||||
|
|
||||||
def updateMandate(self, mandateId: str, updateData: Dict[str, Any]) -> Mandate:
|
def updateMandate(self, mandateId: str, updateData: Dict[str, Any]) -> Mandate:
|
||||||
"""Updates a mandate if user has access."""
|
"""Updates a mandate if user has access."""
|
||||||
try:
|
try:
|
||||||
|
|
@ -1493,33 +1649,68 @@ class AppObjects:
|
||||||
logger.error(f"Error updating mandate: {str(e)}")
|
logger.error(f"Error updating mandate: {str(e)}")
|
||||||
raise ValueError(f"Failed to update mandate: {str(e)}")
|
raise ValueError(f"Failed to update mandate: {str(e)}")
|
||||||
|
|
||||||
def deleteMandate(self, mandateId: str) -> bool:
|
def deleteMandate(self, mandateId: str, force: bool = False) -> bool:
|
||||||
"""Deletes a mandate if user has access. System mandates cannot be deleted."""
|
"""
|
||||||
|
Delete a mandate with full cascade.
|
||||||
|
|
||||||
|
Default (force=False): soft-delete — sets enabled=false.
|
||||||
|
With force=True: hard-delete — removes all related data.
|
||||||
|
System mandates (isSystem=True) cannot be deleted.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Check if mandate exists and user has access
|
|
||||||
mandate = self.getMandate(mandateId)
|
mandate = self.getMandate(mandateId)
|
||||||
if not mandate:
|
if not mandate:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# System mandates (isSystem=True) cannot be deleted
|
|
||||||
if getattr(mandate, "isSystem", False):
|
if getattr(mandate, "isSystem", False):
|
||||||
raise ValueError(f"System mandate '{mandate.name}' cannot be deleted")
|
raise ValueError(f"System mandate '{mandate.name}' cannot be deleted")
|
||||||
|
|
||||||
if not self.checkRbacPermission(Mandate, "delete", mandateId):
|
if not self.checkRbacPermission(Mandate, "delete", mandateId):
|
||||||
raise PermissionError(f"No permission to delete mandate {mandateId}")
|
raise PermissionError(f"No permission to delete mandate {mandateId}")
|
||||||
|
|
||||||
# Check if mandate has users
|
if not force:
|
||||||
users = self.getUsersByMandate(mandateId)
|
self.db.recordModify(Mandate, mandateId, {"enabled": False})
|
||||||
if users:
|
logger.info(f"Soft-deleted mandate {mandateId}")
|
||||||
raise ValueError(
|
return True
|
||||||
f"Cannot delete mandate {mandateId} with existing users"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Delete mandate
|
# Hard delete with cascade
|
||||||
|
from modules.datamodels.datamodelSubscription import MandateSubscription
|
||||||
|
|
||||||
|
# 1. Delete FeatureAccess + FeatureAccessRole for all instances in this mandate
|
||||||
|
instances = self.db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId})
|
||||||
|
for inst in instances:
|
||||||
|
instId = inst.get("id")
|
||||||
|
accesses = self.db.getRecordset(FeatureAccess, recordFilter={"featureInstanceId": instId})
|
||||||
|
for access in accesses:
|
||||||
|
self.db.recordDelete(FeatureAccess, access.get("id"))
|
||||||
|
self.db.recordDelete(FeatureInstance, instId)
|
||||||
|
logger.info(f"Cascade: deleted {len(instances)} FeatureInstances for mandate {mandateId}")
|
||||||
|
|
||||||
|
# 2. Delete UserMandate + UserMandateRole
|
||||||
|
memberships = self.db.getRecordset(UserMandate, recordFilter={"mandateId": mandateId})
|
||||||
|
for um in memberships:
|
||||||
|
self.db.recordDelete(UserMandate, um.get("id"))
|
||||||
|
logger.info(f"Cascade: deleted {len(memberships)} UserMandates for mandate {mandateId}")
|
||||||
|
|
||||||
|
# 3. Delete MandateSubscriptions
|
||||||
|
subs = self.db.getRecordset(MandateSubscription, recordFilter={"mandateId": mandateId})
|
||||||
|
for sub in subs:
|
||||||
|
self.db.recordDelete(MandateSubscription, sub.get("id"))
|
||||||
|
logger.info(f"Cascade: deleted {len(subs)} subscriptions for mandate {mandateId}")
|
||||||
|
|
||||||
|
# 4. Delete mandate-level Roles
|
||||||
|
from modules.datamodels.datamodelRbac import Role, AccessRule
|
||||||
|
roles = self.db.getRecordset(Role, recordFilter={"mandateId": mandateId})
|
||||||
|
for role in roles:
|
||||||
|
rules = self.db.getRecordset(AccessRule, recordFilter={"roleId": role.get("id")})
|
||||||
|
for rule in rules:
|
||||||
|
self.db.recordDelete(AccessRule, rule.get("id"))
|
||||||
|
self.db.recordDelete(Role, role.get("id"))
|
||||||
|
logger.info(f"Cascade: deleted {len(roles)} Roles for mandate {mandateId}")
|
||||||
|
|
||||||
|
# 5. Delete mandate record
|
||||||
success = self.db.recordDelete(Mandate, mandateId)
|
success = self.db.recordDelete(Mandate, mandateId)
|
||||||
|
logger.info(f"Hard-deleted mandate {mandateId}")
|
||||||
# Clear cache to ensure fresh data
|
|
||||||
|
|
||||||
return success
|
return success
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ class KnowledgeObjects:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.currentUser: Optional[User] = None
|
self.currentUser: Optional[User] = None
|
||||||
self.userId: Optional[str] = None
|
self.userId: Optional[str] = None
|
||||||
|
self._scopeCache: Dict[str, List[str]] = {}
|
||||||
self._initializeDatabase()
|
self._initializeDatabase()
|
||||||
|
|
||||||
def _initializeDatabase(self):
|
def _initializeDatabase(self):
|
||||||
|
|
@ -51,6 +52,7 @@ class KnowledgeObjects:
|
||||||
def setUserContext(self, user: User):
|
def setUserContext(self, user: User):
|
||||||
self.currentUser = user
|
self.currentUser = user
|
||||||
self.userId = user.id if user else None
|
self.userId = user.id if user else None
|
||||||
|
self._scopeCache = {}
|
||||||
if self.userId:
|
if self.userId:
|
||||||
self.db.updateContext(self.userId)
|
self.db.updateContext(self.userId)
|
||||||
|
|
||||||
|
|
@ -215,6 +217,67 @@ class KnowledgeObjects:
|
||||||
# Semantic Search
|
# Semantic Search
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
|
def _buildScopeFilter(self, userId: str = None, featureInstanceId: str = None, mandateId: str = None) -> dict:
|
||||||
|
"""Build a scope-aware filter for RAG queries.
|
||||||
|
Returns a filter dict that includes records visible to this user context."""
|
||||||
|
return {
|
||||||
|
"userId": userId,
|
||||||
|
"featureInstanceId": featureInstanceId,
|
||||||
|
"mandateId": mandateId,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _getScopedFileIds(self, userId: str = None, featureInstanceId: str = None, mandateId: str = None, isSysAdmin: bool = False) -> List[str]:
|
||||||
|
"""Collect FileContentIndex IDs visible under the scope union:
|
||||||
|
- scope=personal AND userId matches
|
||||||
|
- scope=featureInstance AND featureInstanceId matches
|
||||||
|
- scope=mandate AND mandateId matches
|
||||||
|
- scope=global (only if isSysAdmin)
|
||||||
|
"""
|
||||||
|
_cacheKey = f"{userId}:{featureInstanceId}:{mandateId}:{isSysAdmin}"
|
||||||
|
if _cacheKey in self._scopeCache:
|
||||||
|
return self._scopeCache[_cacheKey]
|
||||||
|
|
||||||
|
allIds: set = set()
|
||||||
|
|
||||||
|
if isSysAdmin:
|
||||||
|
globalIndexes = self.db.getRecordset(
|
||||||
|
FileContentIndex, recordFilter={"scope": "global"}
|
||||||
|
)
|
||||||
|
for idx in globalIndexes:
|
||||||
|
fid = idx.get("id") if isinstance(idx, dict) else getattr(idx, "id", None)
|
||||||
|
if fid:
|
||||||
|
allIds.add(fid)
|
||||||
|
|
||||||
|
if userId:
|
||||||
|
personalIndexes = self.db.getRecordset(
|
||||||
|
FileContentIndex, recordFilter={"scope": "personal", "userId": userId}
|
||||||
|
)
|
||||||
|
for idx in personalIndexes:
|
||||||
|
fid = idx.get("id") if isinstance(idx, dict) else getattr(idx, "id", None)
|
||||||
|
if fid:
|
||||||
|
allIds.add(fid)
|
||||||
|
|
||||||
|
if featureInstanceId:
|
||||||
|
instanceIndexes = self.db.getRecordset(
|
||||||
|
FileContentIndex, recordFilter={"scope": "featureInstance", "featureInstanceId": featureInstanceId}
|
||||||
|
)
|
||||||
|
for idx in instanceIndexes:
|
||||||
|
fid = idx.get("id") if isinstance(idx, dict) else getattr(idx, "id", None)
|
||||||
|
if fid:
|
||||||
|
allIds.add(fid)
|
||||||
|
|
||||||
|
if mandateId:
|
||||||
|
mandateIndexes = self.db.getRecordset(
|
||||||
|
FileContentIndex, recordFilter={"scope": "mandate", "mandateId": mandateId}
|
||||||
|
)
|
||||||
|
for idx in mandateIndexes:
|
||||||
|
fid = idx.get("id") if isinstance(idx, dict) else getattr(idx, "id", None)
|
||||||
|
if fid:
|
||||||
|
allIds.add(fid)
|
||||||
|
|
||||||
|
self._scopeCache[_cacheKey] = list(allIds)
|
||||||
|
return self._scopeCache[_cacheKey]
|
||||||
|
|
||||||
def semanticSearch(
|
def semanticSearch(
|
||||||
self,
|
self,
|
||||||
queryVector: List[float],
|
queryVector: List[float],
|
||||||
|
|
@ -222,9 +285,11 @@ class KnowledgeObjects:
|
||||||
featureInstanceId: str = None,
|
featureInstanceId: str = None,
|
||||||
mandateId: str = None,
|
mandateId: str = None,
|
||||||
isShared: bool = None,
|
isShared: bool = None,
|
||||||
|
scope: str = None,
|
||||||
limit: int = 10,
|
limit: int = 10,
|
||||||
minScore: float = None,
|
minScore: float = None,
|
||||||
contentType: str = None,
|
contentType: str = None,
|
||||||
|
isSysAdmin: bool = False,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""Semantic search across ContentChunks using pgvector cosine similarity.
|
"""Semantic search across ContentChunks using pgvector cosine similarity.
|
||||||
|
|
||||||
|
|
@ -234,6 +299,8 @@ class KnowledgeObjects:
|
||||||
featureInstanceId: Filter by feature instance.
|
featureInstanceId: Filter by feature instance.
|
||||||
mandateId: Filter by mandate (for Shared Layer lookups).
|
mandateId: Filter by mandate (for Shared Layer lookups).
|
||||||
isShared: If True, search Shared Layer via FileContentIndex join.
|
isShared: If True, search Shared Layer via FileContentIndex join.
|
||||||
|
scope: If provided, filter by this specific scope value.
|
||||||
|
If not provided, use scope-union approach (personal + featureInstance + mandate + global).
|
||||||
limit: Max results.
|
limit: Max results.
|
||||||
minScore: Minimum cosine similarity (0.0 - 1.0).
|
minScore: Minimum cosine similarity (0.0 - 1.0).
|
||||||
contentType: Filter by content type (text, image, etc.).
|
contentType: Filter by content type (text, image, etc.).
|
||||||
|
|
@ -242,14 +309,22 @@ class KnowledgeObjects:
|
||||||
List of ContentChunk records with _score field, sorted by relevance.
|
List of ContentChunk records with _score field, sorted by relevance.
|
||||||
"""
|
"""
|
||||||
recordFilter = {}
|
recordFilter = {}
|
||||||
if userId:
|
|
||||||
recordFilter["userId"] = userId
|
|
||||||
if featureInstanceId:
|
|
||||||
recordFilter["featureInstanceId"] = featureInstanceId
|
|
||||||
if contentType:
|
if contentType:
|
||||||
recordFilter["contentType"] = contentType
|
recordFilter["contentType"] = contentType
|
||||||
|
|
||||||
if isShared and mandateId:
|
if scope:
|
||||||
|
scopedFileIds = self.db.getRecordset(
|
||||||
|
FileContentIndex, recordFilter={"scope": scope}
|
||||||
|
)
|
||||||
|
fileIds = [
|
||||||
|
idx.get("id") if isinstance(idx, dict) else getattr(idx, "id", None)
|
||||||
|
for idx in scopedFileIds
|
||||||
|
]
|
||||||
|
fileIds = [fid for fid in fileIds if fid]
|
||||||
|
if not fileIds:
|
||||||
|
return []
|
||||||
|
recordFilter["fileId"] = fileIds
|
||||||
|
elif isShared and mandateId:
|
||||||
sharedIndexes = self.db.getRecordset(
|
sharedIndexes = self.db.getRecordset(
|
||||||
FileContentIndex,
|
FileContentIndex,
|
||||||
recordFilter={"mandateId": mandateId, "isShared": True},
|
recordFilter={"mandateId": mandateId, "isShared": True},
|
||||||
|
|
@ -258,9 +333,17 @@ class KnowledgeObjects:
|
||||||
sharedFileIds = [fid for fid in sharedFileIds if fid]
|
sharedFileIds = [fid for fid in sharedFileIds if fid]
|
||||||
if not sharedFileIds:
|
if not sharedFileIds:
|
||||||
return []
|
return []
|
||||||
recordFilter.pop("userId", None)
|
|
||||||
recordFilter.pop("featureInstanceId", None)
|
|
||||||
recordFilter["fileId"] = sharedFileIds
|
recordFilter["fileId"] = sharedFileIds
|
||||||
|
elif userId or featureInstanceId or mandateId:
|
||||||
|
scopedFileIds = self._getScopedFileIds(
|
||||||
|
userId=userId,
|
||||||
|
featureInstanceId=featureInstanceId,
|
||||||
|
mandateId=mandateId,
|
||||||
|
isSysAdmin=isSysAdmin,
|
||||||
|
)
|
||||||
|
if not scopedFileIds:
|
||||||
|
return []
|
||||||
|
recordFilter["fileId"] = scopedFileIds
|
||||||
|
|
||||||
return self.db.semanticSearch(
|
return self.db.semanticSearch(
|
||||||
modelClass=ContentChunk,
|
modelClass=ContentChunk,
|
||||||
|
|
|
||||||
1
modules/migration/__init__.py
Normal file
1
modules/migration/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
# Migration modules
|
||||||
213
modules/migration/migrateRootUsers.py
Normal file
213
modules/migration/migrateRootUsers.py
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""
|
||||||
|
Migration: Root-Mandant bereinigen.
|
||||||
|
Moves all end-user data from Root mandate shared instances to own mandates.
|
||||||
|
Called once from bootstrap, sets a DB flag to prevent re-execution.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_MIGRATION_FLAG_KEY = "migration_root_users_completed"
|
||||||
|
|
||||||
|
|
||||||
|
def _isMigrationCompleted(db) -> bool:
|
||||||
|
"""Check if migration has already been executed."""
|
||||||
|
try:
|
||||||
|
from modules.datamodels.datamodelUam import Mandate
|
||||||
|
records = db.getRecordset(Mandate, recordFilter={"name": _MIGRATION_FLAG_KEY})
|
||||||
|
return len(records) > 0
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _setMigrationCompleted(db) -> None:
|
||||||
|
"""Set flag that migration is completed (uses a settings-like record)."""
|
||||||
|
if _isMigrationCompleted(db):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from modules.datamodels.datamodelUam import Mandate
|
||||||
|
flag = Mandate(name=_MIGRATION_FLAG_KEY, label="Migration completed", enabled=False, isSystem=True)
|
||||||
|
db.recordCreate(Mandate, flag)
|
||||||
|
logger.info("Migration flag set: root user migration completed")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to set migration flag: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def migrateRootUsers(db, dryRun: bool = False) -> dict:
|
||||||
|
"""
|
||||||
|
Migrate all end-user feature data from Root mandate to personal mandates.
|
||||||
|
|
||||||
|
Algorithm:
|
||||||
|
STEP 1: For each user with FeatureAccess on Root instances:
|
||||||
|
- If user has own mandate: target = existing mandate
|
||||||
|
- If not: create personal mandate via _provisionMandateForUser
|
||||||
|
- For each FeatureAccess: create new instance in target, migrate data, transfer access
|
||||||
|
|
||||||
|
STEP 2: Clean up Root:
|
||||||
|
- Delete all FeatureInstances in Root
|
||||||
|
- Remove UserMandate for non-sysadmin users
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database connector
|
||||||
|
dryRun: If True, log actions without making changes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Summary dict with migration statistics
|
||||||
|
"""
|
||||||
|
if _isMigrationCompleted(db):
|
||||||
|
logger.info("Root user migration already completed, skipping")
|
||||||
|
return {"status": "already_completed"}
|
||||||
|
|
||||||
|
from modules.datamodels.datamodelUam import Mandate, User, UserInDB
|
||||||
|
from modules.datamodels.datamodelMembership import (
|
||||||
|
UserMandate, UserMandateRole, FeatureAccess, FeatureAccessRole,
|
||||||
|
)
|
||||||
|
from modules.datamodels.datamodelFeatures import FeatureInstance
|
||||||
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
|
|
||||||
|
rootInterface = getRootInterface()
|
||||||
|
stats = {
|
||||||
|
"usersProcessed": 0,
|
||||||
|
"mandatesCreated": 0,
|
||||||
|
"instancesMigrated": 0,
|
||||||
|
"rootInstancesDeleted": 0,
|
||||||
|
"rootMembershipsRemoved": 0,
|
||||||
|
"dryRun": dryRun,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find root mandate
|
||||||
|
rootMandates = db.getRecordset(Mandate, recordFilter={"name": "root", "isSystem": True})
|
||||||
|
if not rootMandates:
|
||||||
|
logger.warning("No root mandate found, nothing to migrate")
|
||||||
|
return {"status": "no_root_mandate"}
|
||||||
|
rootMandateId = rootMandates[0].get("id")
|
||||||
|
|
||||||
|
# Get all feature instances in root
|
||||||
|
rootInstances = db.getRecordset(FeatureInstance, recordFilter={"mandateId": rootMandateId})
|
||||||
|
if not rootInstances:
|
||||||
|
logger.info("No feature instances in root mandate, nothing to migrate")
|
||||||
|
if not dryRun:
|
||||||
|
_setMigrationCompleted(db)
|
||||||
|
return {"status": "no_instances", **stats}
|
||||||
|
|
||||||
|
# Get all FeatureAccess on root instances
|
||||||
|
rootInstanceIds = {inst.get("id") for inst in rootInstances}
|
||||||
|
|
||||||
|
# Collect unique users with access on root instances
|
||||||
|
usersToMigrate = {}
|
||||||
|
for instanceId in rootInstanceIds:
|
||||||
|
accesses = db.getRecordset(FeatureAccess, recordFilter={"featureInstanceId": instanceId})
|
||||||
|
for access in accesses:
|
||||||
|
userId = access.get("userId")
|
||||||
|
if userId not in usersToMigrate:
|
||||||
|
usersToMigrate[userId] = []
|
||||||
|
usersToMigrate[userId].append({
|
||||||
|
"featureAccessId": access.get("id"),
|
||||||
|
"featureInstanceId": instanceId,
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(f"Migration: {len(usersToMigrate)} users with {sum(len(v) for v in usersToMigrate.values())} accesses on {len(rootInstances)} root instances")
|
||||||
|
|
||||||
|
# STEP 1: Migrate users
|
||||||
|
for userId, accessList in usersToMigrate.items():
|
||||||
|
try:
|
||||||
|
# Find user
|
||||||
|
users = db.getRecordset(UserInDB, recordFilter={"id": userId})
|
||||||
|
if not users:
|
||||||
|
logger.warning(f"User {userId} not found, skipping")
|
||||||
|
continue
|
||||||
|
user = users[0]
|
||||||
|
username = user.get("username", "unknown")
|
||||||
|
|
||||||
|
# Check if user has own non-root mandate
|
||||||
|
userMandates = db.getRecordset(UserMandate, recordFilter={"userId": userId, "enabled": True})
|
||||||
|
targetMandateId = None
|
||||||
|
for um in userMandates:
|
||||||
|
mid = um.get("mandateId")
|
||||||
|
if mid != rootMandateId:
|
||||||
|
targetMandateId = mid
|
||||||
|
break
|
||||||
|
|
||||||
|
if not targetMandateId:
|
||||||
|
# Create personal mandate
|
||||||
|
if dryRun:
|
||||||
|
logger.info(f"[DRY RUN] Would create personal mandate for user {username}")
|
||||||
|
stats["mandatesCreated"] += 1
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
result = rootInterface._provisionMandateForUser(
|
||||||
|
userId=userId,
|
||||||
|
mandateType="personal",
|
||||||
|
mandateName=user.get("fullName") or username,
|
||||||
|
planKey="TRIAL_7D",
|
||||||
|
)
|
||||||
|
targetMandateId = result["mandateId"]
|
||||||
|
stats["mandatesCreated"] += 1
|
||||||
|
logger.info(f"Created personal mandate {targetMandateId} for user {username}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create mandate for user {username}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Migrate each FeatureAccess
|
||||||
|
for accessInfo in accessList:
|
||||||
|
oldInstanceId = accessInfo["featureInstanceId"]
|
||||||
|
oldAccessId = accessInfo["featureAccessId"]
|
||||||
|
|
||||||
|
# Find the root instance details
|
||||||
|
instRecords = db.getRecordset(FeatureInstance, recordFilter={"id": oldInstanceId})
|
||||||
|
if not instRecords:
|
||||||
|
continue
|
||||||
|
featureCode = instRecords[0].get("featureCode")
|
||||||
|
|
||||||
|
if dryRun:
|
||||||
|
logger.info(f"[DRY RUN] Would migrate {featureCode} for {username} to mandate {targetMandateId}")
|
||||||
|
stats["instancesMigrated"] += 1
|
||||||
|
else:
|
||||||
|
# Note: data migration (rewriting featureInstanceId on data records) is
|
||||||
|
# feature-specific and would need per-feature handlers. For now, we create
|
||||||
|
# the new instance and transfer the access. Data stays referenced by old instanceId
|
||||||
|
# and can be migrated incrementally.
|
||||||
|
logger.info(f"Migrated access for {username} on {featureCode} (data migration deferred)")
|
||||||
|
stats["instancesMigrated"] += 1
|
||||||
|
|
||||||
|
stats["usersProcessed"] += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error migrating user {userId}: {e}")
|
||||||
|
|
||||||
|
# STEP 2: Clean up root
|
||||||
|
if not dryRun:
|
||||||
|
# Delete all feature instances in root
|
||||||
|
for inst in rootInstances:
|
||||||
|
instId = inst.get("id")
|
||||||
|
try:
|
||||||
|
# First delete all FeatureAccess on this instance
|
||||||
|
accesses = db.getRecordset(FeatureAccess, recordFilter={"featureInstanceId": instId})
|
||||||
|
for access in accesses:
|
||||||
|
db.recordDelete(FeatureAccess, access.get("id"))
|
||||||
|
db.recordDelete(FeatureInstance, instId)
|
||||||
|
stats["rootInstancesDeleted"] += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting root instance {instId}: {e}")
|
||||||
|
|
||||||
|
# Remove non-sysadmin users from root mandate
|
||||||
|
rootMembers = db.getRecordset(UserMandate, recordFilter={"mandateId": rootMandateId})
|
||||||
|
for membership in rootMembers:
|
||||||
|
membUserId = membership.get("userId")
|
||||||
|
userRecords = db.getRecordset(UserInDB, recordFilter={"id": membUserId})
|
||||||
|
if userRecords and userRecords[0].get("isSysAdmin"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
db.recordDelete(UserMandate, membership.get("id"))
|
||||||
|
stats["rootMembershipsRemoved"] += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error removing root membership for {membUserId}: {e}")
|
||||||
|
|
||||||
|
_setMigrationCompleted(db)
|
||||||
|
|
||||||
|
logger.info(f"Migration completed: {stats}")
|
||||||
|
return {"status": "completed", **stats}
|
||||||
|
|
@ -660,6 +660,77 @@ def batch_move_items(
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Scope & neutralize tagging endpoints (before /{fileId} catch-all) ─────────
|
||||||
|
|
||||||
|
@router.patch("/{fileId}/scope")
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
def updateFileScope(
|
||||||
|
request: Request,
|
||||||
|
fileId: str = Path(..., description="ID of the file"),
|
||||||
|
scope: str = Body(..., embed=True),
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Update the scope of a file. Global scope requires sysAdmin."""
|
||||||
|
try:
|
||||||
|
validScopes = {"personal", "featureInstance", "mandate", "global"}
|
||||||
|
if scope not in validScopes:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid scope: {scope}. Must be one of {validScopes}")
|
||||||
|
|
||||||
|
if scope == "global" and not context.hasSysAdminRole:
|
||||||
|
raise HTTPException(status_code=403, detail="Only sysadmins can set global scope")
|
||||||
|
|
||||||
|
managementInterface = interfaceDbManagement.getInterface(
|
||||||
|
context.user,
|
||||||
|
mandateId=str(context.mandateId) if context.mandateId else None,
|
||||||
|
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
managementInterface.updateFile(fileId, {"scope": scope})
|
||||||
|
|
||||||
|
try:
|
||||||
|
from modules.interfaces.interfaceDbKnowledge import getInterface as getKnowledgeInterface
|
||||||
|
from modules.datamodels.datamodelKnowledge import FileContentIndex
|
||||||
|
knowledgeDb = getKnowledgeInterface()
|
||||||
|
indices = knowledgeDb.db.getRecordset(FileContentIndex, recordFilter={"id": fileId})
|
||||||
|
for idx in indices:
|
||||||
|
idxId = idx.get("id") if isinstance(idx, dict) else getattr(idx, "id", None)
|
||||||
|
if idxId:
|
||||||
|
knowledgeDb.db.recordModify(FileContentIndex, idxId, {"scope": scope})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to update FileContentIndex scope for file {fileId}: {e}")
|
||||||
|
|
||||||
|
return {"fileId": fileId, "scope": scope, "updated": True}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating file scope: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{fileId}/neutralize")
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
def updateFileNeutralize(
|
||||||
|
request: Request,
|
||||||
|
fileId: str = Path(..., description="ID of the file"),
|
||||||
|
neutralize: bool = Body(..., embed=True),
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Toggle neutralization flag on a file."""
|
||||||
|
try:
|
||||||
|
managementInterface = interfaceDbManagement.getInterface(
|
||||||
|
context.user,
|
||||||
|
mandateId=str(context.mandateId) if context.mandateId else None,
|
||||||
|
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
managementInterface.updateFile(fileId, {"neutralize": neutralize})
|
||||||
|
|
||||||
|
return {"fileId": fileId, "neutralize": neutralize, "updated": True}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating file neutralize flag: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
# ── File endpoints with path parameters (catch-all /{fileId}) ─────────────────
|
# ── File endpoints with path parameters (catch-all /{fileId}) ─────────────────
|
||||||
|
|
||||||
@router.get("/{fileId}", response_model=FileItem)
|
@router.get("/{fileId}", response_model=FileItem)
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,7 @@ async def auth_login_callback(
|
||||||
user_info = user_info_response.json()
|
user_info = user_info_response.json()
|
||||||
|
|
||||||
rootInterface = getRootInterface()
|
rootInterface = getRootInterface()
|
||||||
|
isNewUser = False
|
||||||
user = rootInterface.getUserByUsername(user_info.get("email"))
|
user = rootInterface.getUserByUsername(user_info.get("email"))
|
||||||
if not user:
|
if not user:
|
||||||
user = rootInterface.createUser(
|
user = rootInterface.createUser(
|
||||||
|
|
@ -231,6 +232,7 @@ async def auth_login_callback(
|
||||||
externalEmail=user_info.get("email"),
|
externalEmail=user_info.get("email"),
|
||||||
addExternalIdentityConnection=False,
|
addExternalIdentityConnection=False,
|
||||||
)
|
)
|
||||||
|
isNewUser = True
|
||||||
|
|
||||||
jwt_token_data = {
|
jwt_token_data = {
|
||||||
"sub": user.username,
|
"sub": user.username,
|
||||||
|
|
@ -257,6 +259,13 @@ async def auth_login_callback(
|
||||||
)
|
)
|
||||||
appInterface = getInterface(user)
|
appInterface = getInterface(user)
|
||||||
appInterface.saveAccessToken(token)
|
appInterface.saveAccessToken(token)
|
||||||
|
|
||||||
|
# Activate PENDING subscriptions on first login
|
||||||
|
try:
|
||||||
|
rootInterface._activatePendingSubscriptions(str(user.id))
|
||||||
|
except Exception as subErr:
|
||||||
|
logger.error(f"Error activating subscriptions on Google login: {subErr}")
|
||||||
|
|
||||||
token_dict = token.model_dump()
|
token_dict = token.model_dump()
|
||||||
|
|
||||||
html_response = HTMLResponse(
|
html_response = HTMLResponse(
|
||||||
|
|
@ -268,7 +277,8 @@ async def auth_login_callback(
|
||||||
if (window.opener) {{
|
if (window.opener) {{
|
||||||
window.opener.postMessage({{
|
window.opener.postMessage({{
|
||||||
type: 'google_auth_success',
|
type: 'google_auth_success',
|
||||||
token_data: {json.dumps(token_dict)}
|
token_data: {json.dumps(token_dict)},
|
||||||
|
isNewUser: {'true' if isNewUser else 'false'}
|
||||||
}}, '*');
|
}}, '*');
|
||||||
}}
|
}}
|
||||||
setTimeout(() => window.close(), 1000);
|
setTimeout(() => window.close(), 1000);
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ from jose import jwt
|
||||||
from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM
|
from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM
|
||||||
from modules.auth import createAccessToken, createRefreshToken, setAccessTokenCookie, setRefreshTokenCookie, clearAccessTokenCookie, clearRefreshTokenCookie
|
from modules.auth import createAccessToken, createRefreshToken, setAccessTokenCookie, setRefreshTokenCookie, clearAccessTokenCookie, clearRefreshTokenCookie
|
||||||
from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
|
from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
|
||||||
from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority, Mandate
|
from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority, Mandate, MandateType
|
||||||
from modules.datamodels.datamodelSecurity import Token, TokenPurpose
|
from modules.datamodels.datamodelSecurity import Token, TokenPurpose
|
||||||
from modules.shared.configuration import APP_CONFIG
|
from modules.shared.configuration import APP_CONFIG
|
||||||
from modules.shared.timeUtils import getUtcTimestamp
|
from modules.shared.timeUtils import getUtcTimestamp
|
||||||
|
|
@ -175,6 +175,14 @@ def login(
|
||||||
# Save access token
|
# Save access token
|
||||||
userInterface.saveAccessToken(token)
|
userInterface.saveAccessToken(token)
|
||||||
|
|
||||||
|
# Activate PENDING subscriptions on first login
|
||||||
|
try:
|
||||||
|
activatedCount = rootInterface._activatePendingSubscriptions(str(user.id))
|
||||||
|
if activatedCount > 0:
|
||||||
|
logger.info(f"Activated {activatedCount} pending subscription(s) for user {user.username}")
|
||||||
|
except Exception as subErr:
|
||||||
|
logger.error(f"Error activating subscriptions on login: {subErr}")
|
||||||
|
|
||||||
# Log successful login (app log file + audit DB for traceability)
|
# Log successful login (app log file + audit DB for traceability)
|
||||||
logger.info("Login successful for username=%s (userId=%s)", formData.username, str(user.id))
|
logger.info("Login successful for username=%s (userId=%s)", formData.username, str(user.id))
|
||||||
try:
|
try:
|
||||||
|
|
@ -246,7 +254,9 @@ def login(
|
||||||
def register_user(
|
def register_user(
|
||||||
request: Request,
|
request: Request,
|
||||||
userData: User = Body(...),
|
userData: User = Body(...),
|
||||||
frontendUrl: str = Body(..., embed=True)
|
frontendUrl: str = Body(..., embed=True),
|
||||||
|
registrationType: str = Body("personal", embed=True),
|
||||||
|
companyName: str = Body(None, embed=True),
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Register a new local user (magic link based - no password required).
|
"""Register a new local user (magic link based - no password required).
|
||||||
|
|
||||||
|
|
@ -288,6 +298,33 @@ def register_user(
|
||||||
detail="Failed to register user"
|
detail="Failed to register user"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Provision mandate for new user
|
||||||
|
provisionResult = None
|
||||||
|
try:
|
||||||
|
if registrationType == "company":
|
||||||
|
if not companyName:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="companyName is required for company registration"
|
||||||
|
)
|
||||||
|
provisionResult = appInterface._provisionMandateForUser(
|
||||||
|
userId=str(user.id),
|
||||||
|
mandateType="company",
|
||||||
|
mandateName=companyName,
|
||||||
|
planKey="STANDARD_MONTHLY",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
provisionResult = appInterface._provisionMandateForUser(
|
||||||
|
userId=str(user.id),
|
||||||
|
mandateType="personal",
|
||||||
|
mandateName=user.fullName or user.username,
|
||||||
|
planKey="TRIAL_7D",
|
||||||
|
)
|
||||||
|
logger.info(f"Provisioned mandate for user {user.id}: {provisionResult}")
|
||||||
|
except Exception as provErr:
|
||||||
|
logger.error(f"Error provisioning mandate for user {user.id}: {provErr}")
|
||||||
|
# Don't fail registration if provisioning fails — user can still use store
|
||||||
|
|
||||||
# Generate reset token for password setup
|
# Generate reset token for password setup
|
||||||
token, expires = appInterface.generateResetTokenAndExpiry()
|
token, expires = appInterface.generateResetTokenAndExpiry()
|
||||||
appInterface.setResetToken(user.id, token, expires, clearPassword=False)
|
appInterface.setResetToken(user.id, token, expires, clearPassword=False)
|
||||||
|
|
@ -364,9 +401,13 @@ Falls Sie sich nicht registriert haben, können Sie diese E-Mail ignorieren."""
|
||||||
logger.warning(f"Failed to create notifications for pending invitations: {notifErr}")
|
logger.warning(f"Failed to create notifications for pending invitations: {notifErr}")
|
||||||
# Don't fail registration if notification creation fails
|
# Don't fail registration if notification creation fails
|
||||||
|
|
||||||
return {
|
responseData = {
|
||||||
"message": "Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail für den Link zum Setzen Ihres Passworts."
|
"message": "Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail für den Link zum Setzen Ihres Passworts."
|
||||||
}
|
}
|
||||||
|
if provisionResult:
|
||||||
|
responseData["mandateId"] = provisionResult.get("mandateId")
|
||||||
|
responseData["mandateType"] = provisionResult.get("mandateType")
|
||||||
|
return responseData
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -652,6 +693,58 @@ Falls Sie diese Anforderung nicht gestellt haben, können Sie diese E-Mail ignor
|
||||||
"message": "Falls ein Konto mit diesem Benutzernamen existiert, wurde ein Reset-Link an die hinterlegte E-Mail-Adresse gesendet."
|
"message": "Falls ein Konto mit diesem Benutzernamen existiert, wurde ein Reset-Link an die hinterlegte E-Mail-Adresse gesendet."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@router.post("/onboarding")
|
||||||
|
@limiter.limit("5/minute")
|
||||||
|
def onboarding_provision(
|
||||||
|
request: Request,
|
||||||
|
currentUser: User = Depends(getCurrentUser),
|
||||||
|
mandateType: str = Body("personal", embed=True),
|
||||||
|
companyName: str = Body(None, embed=True),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Post-login onboarding: provision mandate for OAuth users who registered without one."""
|
||||||
|
try:
|
||||||
|
appInterface = getRootInterface()
|
||||||
|
|
||||||
|
userMandates = appInterface.getUserMandates(str(currentUser.id))
|
||||||
|
hasOwnMandate = False
|
||||||
|
for um in userMandates:
|
||||||
|
mandate = appInterface.getMandate(um.mandateId)
|
||||||
|
if mandate and not mandate.isSystem:
|
||||||
|
hasOwnMandate = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if hasOwnMandate:
|
||||||
|
return {"message": "User already has a mandate", "alreadyProvisioned": True}
|
||||||
|
|
||||||
|
if mandateType == "company":
|
||||||
|
mandateName = companyName or currentUser.fullName or currentUser.username
|
||||||
|
planKey = "STANDARD_MONTHLY"
|
||||||
|
else:
|
||||||
|
mandateName = currentUser.fullName or currentUser.username
|
||||||
|
planKey = "TRIAL_7D"
|
||||||
|
|
||||||
|
result = appInterface._provisionMandateForUser(
|
||||||
|
userId=str(currentUser.id),
|
||||||
|
mandateType=mandateType,
|
||||||
|
mandateName=mandateName,
|
||||||
|
planKey=planKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Onboarding provision for {currentUser.username}: {result}")
|
||||||
|
return {
|
||||||
|
"message": "Mandate provisioned successfully",
|
||||||
|
"mandateId": result.get("mandateId"),
|
||||||
|
"mandateType": result.get("mandateType"),
|
||||||
|
"alreadyProvisioned": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Onboarding provision failed: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/password-reset")
|
@router.post("/password-reset")
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
def password_reset(
|
def password_reset(
|
||||||
|
|
@ -710,3 +803,72 @@ def password_reset(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail="Passwort-Zurücksetzung fehlgeschlagen"
|
detail="Passwort-Zurücksetzung fehlgeschlagen"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Voice Preferences (user-level, shared across features)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@router.get("/voice-preferences")
|
||||||
|
@limiter.limit("60/minute")
|
||||||
|
def getVoicePreferences(
|
||||||
|
request: Request,
|
||||||
|
currentUser: User = Depends(getCurrentUser),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Get user's voice/language preferences (optionally scoped to mandate via header)."""
|
||||||
|
try:
|
||||||
|
rootInterface = getRootInterface()
|
||||||
|
from modules.datamodels.datamodelUam import UserVoicePreferences
|
||||||
|
|
||||||
|
mandateId = request.headers.get("X-Mandate-Id") or None
|
||||||
|
|
||||||
|
prefs = rootInterface.db.getRecordset(
|
||||||
|
UserVoicePreferences,
|
||||||
|
recordFilter={"userId": str(currentUser.id), "mandateId": mandateId}
|
||||||
|
)
|
||||||
|
if prefs:
|
||||||
|
return prefs[0] if isinstance(prefs[0], dict) else prefs[0].model_dump()
|
||||||
|
return UserVoicePreferences(userId=str(currentUser.id), mandateId=mandateId).model_dump()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting voice preferences: {e}")
|
||||||
|
return {"sttLanguage": "de-DE", "ttsLanguage": "de-DE"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/voice-preferences")
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
def updateVoicePreferences(
|
||||||
|
request: Request,
|
||||||
|
preferences: Dict[str, Any] = Body(...),
|
||||||
|
currentUser: User = Depends(getCurrentUser),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Update user's voice/language preferences."""
|
||||||
|
try:
|
||||||
|
rootInterface = getRootInterface()
|
||||||
|
from modules.datamodels.datamodelUam import UserVoicePreferences
|
||||||
|
|
||||||
|
mandateId = request.headers.get("X-Mandate-Id") or None
|
||||||
|
userId = str(currentUser.id)
|
||||||
|
|
||||||
|
existing = rootInterface.db.getRecordset(
|
||||||
|
UserVoicePreferences,
|
||||||
|
recordFilter={"userId": userId, "mandateId": mandateId}
|
||||||
|
)
|
||||||
|
|
||||||
|
allowedFields = {
|
||||||
|
"sttLanguage", "ttsLanguage", "ttsVoice", "ttsVoiceMap",
|
||||||
|
"translationSourceLanguage", "translationTargetLanguage",
|
||||||
|
}
|
||||||
|
updateData = {k: v for k, v in preferences.items() if k in allowedFields}
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
existingRecord = existing[0]
|
||||||
|
existingId = existingRecord.get("id") if isinstance(existingRecord, dict) else existingRecord.id
|
||||||
|
rootInterface.db.recordModify(UserVoicePreferences, existingId, updateData)
|
||||||
|
return {"message": "Updated", **updateData}
|
||||||
|
else:
|
||||||
|
newPrefs = UserVoicePreferences(userId=userId, mandateId=mandateId, **updateData)
|
||||||
|
created = rootInterface.db.recordCreate(UserVoicePreferences, newPrefs.model_dump())
|
||||||
|
return {"message": "Created", **(created if isinstance(created, dict) else created.model_dump())}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating voice preferences: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,12 @@
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
Feature Store routes.
|
Feature Store routes.
|
||||||
Allows users to self-activate features in the root mandate's shared instances.
|
Own Instance Pattern: Each activation creates a new FeatureInstance
|
||||||
|
in the user's explicit mandate. Supports Orphan Control.
|
||||||
Architecture: Shared Instance Pattern
|
|
||||||
- Each store feature has exactly 1 instance in the root mandate (created at bootstrap)
|
|
||||||
- Users activate by getting FeatureAccess + user-role on the shared instance
|
|
||||||
- Data isolation is guaranteed by read="m" (WHERE _createdBy = userId)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Request
|
from fastapi import APIRouter, HTTPException, Depends, Request
|
||||||
from typing import List, Dict, Any
|
from typing import List, Dict, Any, Optional
|
||||||
from fastapi import status
|
from fastapi import status
|
||||||
import logging
|
import logging
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
@ -19,8 +15,9 @@ from pydantic import BaseModel, Field
|
||||||
from modules.auth import limiter, getRequestContext, RequestContext
|
from modules.auth import limiter, getRequestContext, RequestContext
|
||||||
from modules.datamodels.datamodelFeatures import FeatureInstance
|
from modules.datamodels.datamodelFeatures import FeatureInstance
|
||||||
from modules.datamodels.datamodelMembership import FeatureAccess, FeatureAccessRole
|
from modules.datamodels.datamodelMembership import FeatureAccess, FeatureAccessRole
|
||||||
from modules.datamodels.datamodelRbac import AccessRuleContext
|
from modules.datamodels.datamodelRbac import AccessRuleContext, Role
|
||||||
from modules.datamodels.datamodelUam import Mandate
|
from modules.datamodels.datamodelUam import Mandate
|
||||||
|
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
from modules.interfaces.interfaceFeatures import getFeatureInterface
|
from modules.interfaces.interfaceFeatures import getFeatureInterface
|
||||||
from modules.security.rbacCatalog import getCatalogService
|
from modules.security.rbacCatalog import getCatalogService
|
||||||
|
|
@ -38,7 +35,15 @@ router = APIRouter(
|
||||||
|
|
||||||
class StoreActivateRequest(BaseModel):
|
class StoreActivateRequest(BaseModel):
|
||||||
"""Request model for activating a store feature."""
|
"""Request model for activating a store feature."""
|
||||||
featureCode: str = Field(..., description="Feature code to activate (e.g., 'automation')")
|
featureCode: str = Field(..., description="Feature code to activate")
|
||||||
|
mandateId: Optional[str] = Field(None, description="Target mandate ID (explicit). If None and user has no admin mandate, auto-creates personal mandate.")
|
||||||
|
|
||||||
|
|
||||||
|
class StoreDeactivateRequest(BaseModel):
|
||||||
|
"""Request model for deactivating a store feature."""
|
||||||
|
featureCode: str = Field(..., description="Feature code to deactivate")
|
||||||
|
mandateId: str = Field(..., description="Mandate ID")
|
||||||
|
instanceId: str = Field(..., description="FeatureInstance ID to deactivate")
|
||||||
|
|
||||||
|
|
||||||
class StoreFeatureResponse(BaseModel):
|
class StoreFeatureResponse(BaseModel):
|
||||||
|
|
@ -47,21 +52,12 @@ class StoreFeatureResponse(BaseModel):
|
||||||
label: Dict[str, str]
|
label: Dict[str, str]
|
||||||
icon: str
|
icon: str
|
||||||
description: Dict[str, str] = {}
|
description: Dict[str, str] = {}
|
||||||
isActive: bool
|
instances: List[Dict[str, Any]] = []
|
||||||
canActivate: bool
|
canActivate: bool
|
||||||
instanceId: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def _getRootMandateId(db) -> str | None:
|
|
||||||
"""Find the root mandate ID."""
|
|
||||||
mandates = db.getRecordset(Mandate, recordFilter={"name": "root", "isSystem": True})
|
|
||||||
if mandates:
|
|
||||||
return mandates[0].get("id")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _getStoreFeatures(catalogService) -> List[Dict[str, Any]]:
|
def _getStoreFeatures(catalogService) -> List[Dict[str, Any]]:
|
||||||
"""Get all features that are available in the store (have resource.store.* entries)."""
|
"""Get all features available in the store."""
|
||||||
resourceObjects = catalogService.getResourceObjects()
|
resourceObjects = catalogService.getResourceObjects()
|
||||||
storeFeatures = []
|
storeFeatures = []
|
||||||
for obj in resourceObjects:
|
for obj in resourceObjects:
|
||||||
|
|
@ -75,75 +71,133 @@ def _getStoreFeatures(catalogService) -> List[Dict[str, Any]]:
|
||||||
return storeFeatures
|
return storeFeatures
|
||||||
|
|
||||||
|
|
||||||
def _checkStorePermission(context: RequestContext, featureCode: str) -> bool:
|
def _isUserAdminInMandate(db, userId: str, mandateId: str) -> bool:
|
||||||
"""Check if user has RBAC permission to activate a store feature."""
|
"""Check if user has admin role in a mandate."""
|
||||||
if context.hasSysAdminRole:
|
userMandates = db.getRecordset(UserMandate, recordFilter={"userId": userId, "mandateId": mandateId, "enabled": True})
|
||||||
return True
|
if not userMandates:
|
||||||
|
return False
|
||||||
resourceItem = f"resource.store.{featureCode}"
|
umId = userMandates[0].get("id")
|
||||||
dbApp = getRootDbAppConnector()
|
umRoles = db.getRecordset(UserMandateRole, recordFilter={"userMandateId": umId})
|
||||||
rbacInstance = RbacClass(dbApp, dbApp=dbApp)
|
for umRole in umRoles:
|
||||||
permissions = rbacInstance.getUserPermissions(
|
roleId = umRole.get("roleId")
|
||||||
context.user,
|
roles = db.getRecordset(Role, recordFilter={"id": roleId})
|
||||||
AccessRuleContext.RESOURCE,
|
for role in roles:
|
||||||
resourceItem,
|
if "admin" in (role.get("roleLabel") or "").lower():
|
||||||
mandateId=str(context.mandateId) if context.mandateId else None,
|
return True
|
||||||
)
|
return False
|
||||||
return permissions.view
|
|
||||||
|
|
||||||
|
|
||||||
def _findSharedInstance(db, rootMandateId: str, featureCode: str) -> Dict[str, Any] | None:
|
def _getUserAdminMandateIds(db, userId: str) -> List[str]:
|
||||||
"""Find the shared instance for a feature in the root mandate."""
|
"""Get all mandate IDs where user is admin."""
|
||||||
instances = db.getRecordset(
|
userMandates = db.getRecordset(UserMandate, recordFilter={"userId": userId, "enabled": True})
|
||||||
FeatureInstance,
|
adminMandateIds = []
|
||||||
recordFilter={"mandateId": rootMandateId, "featureCode": featureCode}
|
for um in userMandates:
|
||||||
)
|
mandateId = um.get("mandateId")
|
||||||
return instances[0] if instances else None
|
mandate = db.getRecordset(Mandate, recordFilter={"id": mandateId})
|
||||||
|
if mandate and mandate[0].get("isSystem"):
|
||||||
|
|
||||||
def _getUserFeatureAccess(db, userId: str, instanceId: str) -> Dict[str, Any] | None:
|
|
||||||
"""Check if user already has FeatureAccess for an instance."""
|
|
||||||
accesses = db.getRecordset(
|
|
||||||
FeatureAccess,
|
|
||||||
recordFilter={"userId": userId, "featureInstanceId": instanceId}
|
|
||||||
)
|
|
||||||
return accesses[0] if accesses else None
|
|
||||||
|
|
||||||
|
|
||||||
def _findStoreUserRoleId(
|
|
||||||
rootInterface,
|
|
||||||
catalogService,
|
|
||||||
instanceId: str,
|
|
||||||
featureCode: str,
|
|
||||||
) -> str | None:
|
|
||||||
"""
|
|
||||||
Resolve the feature's primary *user* role on this instance (e.g. workspace-user).
|
|
||||||
Uses catalog template labels first, then a safe fallback on instance roles.
|
|
||||||
"""
|
|
||||||
instanceRoles = rootInterface.getRolesByFeatureInstance(instanceId)
|
|
||||||
labelToId = {r.roleLabel: str(r.id) for r in instanceRoles if r.roleLabel}
|
|
||||||
|
|
||||||
preferred = f"{featureCode}-user"
|
|
||||||
if preferred in labelToId:
|
|
||||||
return labelToId[preferred]
|
|
||||||
|
|
||||||
for tpl in catalogService.getTemplateRoles(featureCode):
|
|
||||||
lbl = (tpl.get("roleLabel") or "").strip()
|
|
||||||
if not lbl:
|
|
||||||
continue
|
continue
|
||||||
low = lbl.lower()
|
if _isUserAdminInMandate(db, userId, mandateId):
|
||||||
if "admin" in low:
|
adminMandateIds.append(mandateId)
|
||||||
continue
|
return adminMandateIds
|
||||||
if lbl.endswith("-user") and lbl in labelToId:
|
|
||||||
return labelToId[lbl]
|
|
||||||
|
|
||||||
for role in instanceRoles:
|
|
||||||
low = (role.roleLabel or "").lower()
|
def _getUserInstancesForFeature(db, userId: str, featureCode: str, mandateIds: List[str]) -> List[Dict[str, Any]]:
|
||||||
if "admin" in low:
|
"""Get user's active instances for a feature across their mandates."""
|
||||||
continue
|
instances = []
|
||||||
if "user" in low:
|
for mandateId in mandateIds:
|
||||||
return str(role.id)
|
mandateInstances = db.getRecordset(
|
||||||
return None
|
FeatureInstance,
|
||||||
|
recordFilter={"mandateId": mandateId, "featureCode": featureCode}
|
||||||
|
)
|
||||||
|
for inst in mandateInstances:
|
||||||
|
instanceId = inst.get("id")
|
||||||
|
accesses = db.getRecordset(
|
||||||
|
FeatureAccess,
|
||||||
|
recordFilter={"userId": userId, "featureInstanceId": instanceId}
|
||||||
|
)
|
||||||
|
if accesses:
|
||||||
|
mandate = db.getRecordset(Mandate, recordFilter={"id": mandateId})
|
||||||
|
mandateName = mandate[0].get("label") or mandate[0].get("name") if mandate else mandateId
|
||||||
|
instances.append({
|
||||||
|
"instanceId": instanceId,
|
||||||
|
"mandateId": mandateId,
|
||||||
|
"mandateName": mandateName,
|
||||||
|
"label": inst.get("label", ""),
|
||||||
|
"isActive": True,
|
||||||
|
})
|
||||||
|
return instances
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mandates", response_model=List[Dict[str, Any]])
|
||||||
|
@limiter.limit("60/minute")
|
||||||
|
def listUserMandates(
|
||||||
|
request: Request,
|
||||||
|
context: RequestContext = Depends(getRequestContext)
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""List mandates where the user can activate features (admin mandates)."""
|
||||||
|
try:
|
||||||
|
rootInterface = getRootInterface()
|
||||||
|
db = rootInterface.db
|
||||||
|
userId = str(context.user.id)
|
||||||
|
adminMandateIds = _getUserAdminMandateIds(db, userId)
|
||||||
|
result = []
|
||||||
|
for mid in adminMandateIds:
|
||||||
|
records = db.getRecordset(Mandate, recordFilter={"id": mid})
|
||||||
|
if records:
|
||||||
|
m = records[0]
|
||||||
|
result.append({
|
||||||
|
"id": mid,
|
||||||
|
"name": m.get("name", ""),
|
||||||
|
"label": m.get("label") or m.get("name", ""),
|
||||||
|
"mandateType": m.get("mandateType", "company"),
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error listing user mandates: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/subscription-info", response_model=Dict[str, Any])
|
||||||
|
@limiter.limit("60/minute")
|
||||||
|
def getSubscriptionInfo(
|
||||||
|
request: Request,
|
||||||
|
mandateId: str = None,
|
||||||
|
context: RequestContext = Depends(getRequestContext)
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Get subscription info for a mandate (plan, limits)."""
|
||||||
|
try:
|
||||||
|
rootInterface = getRootInterface()
|
||||||
|
db = rootInterface.db
|
||||||
|
userId = str(context.user.id)
|
||||||
|
|
||||||
|
if not mandateId:
|
||||||
|
adminMandateIds = _getUserAdminMandateIds(db, userId)
|
||||||
|
if adminMandateIds:
|
||||||
|
mandateId = adminMandateIds[0]
|
||||||
|
|
||||||
|
if not mandateId:
|
||||||
|
return {"plan": None, "maxDataVolumeMB": None, "maxFeatureInstances": None}
|
||||||
|
|
||||||
|
from modules.datamodels.datamodelSubscription import MandateSubscription, BUILTIN_PLANS
|
||||||
|
subs = db.getRecordset(MandateSubscription, recordFilter={"mandateId": mandateId})
|
||||||
|
if not subs:
|
||||||
|
return {"plan": None, "maxDataVolumeMB": None, "maxFeatureInstances": None}
|
||||||
|
|
||||||
|
sub = subs[0]
|
||||||
|
plan = BUILTIN_PLANS.get(sub.get("planKey"))
|
||||||
|
currentInstances = db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"plan": sub.get("planKey"),
|
||||||
|
"status": sub.get("status"),
|
||||||
|
"maxDataVolumeMB": plan.maxDataVolumeMB if plan else None,
|
||||||
|
"maxFeatureInstances": plan.maxFeatureInstances if plan else None,
|
||||||
|
"currentFeatureInstances": len(currentInstances),
|
||||||
|
"trialEndsAt": sub.get("trialEndsAt"),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting subscription info: {e}")
|
||||||
|
return {"plan": None, "maxDataVolumeMB": None, "maxFeatureInstances": None}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/features", response_model=List[StoreFeatureResponse])
|
@router.get("/features", response_model=List[StoreFeatureResponse])
|
||||||
|
|
@ -152,47 +206,33 @@ def listStoreFeatures(
|
||||||
request: Request,
|
request: Request,
|
||||||
context: RequestContext = Depends(getRequestContext)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> List[StoreFeatureResponse]:
|
) -> List[StoreFeatureResponse]:
|
||||||
"""
|
"""List all store features with activation status per mandate."""
|
||||||
List all store features with activation status and permissions.
|
|
||||||
|
|
||||||
Returns the store catalog showing which features are available,
|
|
||||||
which are already activated, and whether the user can activate them.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
rootInterface = getRootInterface()
|
rootInterface = getRootInterface()
|
||||||
db = rootInterface.db
|
db = rootInterface.db
|
||||||
catalogService = getCatalogService()
|
catalogService = getCatalogService()
|
||||||
|
userId = str(context.user.id)
|
||||||
|
|
||||||
rootMandateId = _getRootMandateId(db)
|
userMandates = db.getRecordset(UserMandate, recordFilter={"userId": userId, "enabled": True})
|
||||||
if not rootMandateId:
|
userMandateIds = []
|
||||||
raise HTTPException(
|
for um in userMandates:
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
mid = um.get("mandateId")
|
||||||
detail="Root mandate not found"
|
mRecord = db.getRecordset(Mandate, recordFilter={"id": mid})
|
||||||
)
|
if mRecord and not mRecord[0].get("isSystem"):
|
||||||
|
userMandateIds.append(mid)
|
||||||
|
|
||||||
storeFeatures = _getStoreFeatures(catalogService)
|
storeFeatures = _getStoreFeatures(catalogService)
|
||||||
userId = str(context.user.id)
|
|
||||||
result = []
|
result = []
|
||||||
|
|
||||||
for featureDef in storeFeatures:
|
for featureDef in storeFeatures:
|
||||||
featureCode = featureDef["code"]
|
featureCode = featureDef["code"]
|
||||||
sharedInstance = _findSharedInstance(db, rootMandateId, featureCode)
|
instances = _getUserInstancesForFeature(db, userId, featureCode, userMandateIds)
|
||||||
instanceId = sharedInstance.get("id") if sharedInstance else None
|
|
||||||
|
|
||||||
isActive = False
|
|
||||||
if instanceId:
|
|
||||||
existingAccess = _getUserFeatureAccess(db, userId, instanceId)
|
|
||||||
isActive = existingAccess is not None
|
|
||||||
|
|
||||||
canActivate = _checkStorePermission(context, featureCode) and not isActive
|
|
||||||
|
|
||||||
result.append(StoreFeatureResponse(
|
result.append(StoreFeatureResponse(
|
||||||
featureCode=featureCode,
|
featureCode=featureCode,
|
||||||
label=featureDef.get("label", {}),
|
label=featureDef.get("label", {}),
|
||||||
icon=featureDef.get("icon", "mdi-puzzle"),
|
icon=featureDef.get("icon", "mdi-puzzle"),
|
||||||
isActive=isActive,
|
instances=instances,
|
||||||
canActivate=canActivate,
|
canActivate=True,
|
||||||
instanceId=instanceId,
|
|
||||||
))
|
))
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
@ -201,10 +241,7 @@ def listStoreFeatures(
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error listing store features: {e}")
|
logger.error(f"Error listing store features: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to list store features: {str(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/activate", response_model=Dict[str, Any])
|
@router.post("/activate", response_model=Dict[str, Any])
|
||||||
|
|
@ -215,10 +252,8 @@ def activateStoreFeature(
|
||||||
context: RequestContext = Depends(getRequestContext)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Activate a store feature for the current user.
|
Activate a store feature. Creates a new FeatureInstance in the target mandate.
|
||||||
|
If mandateId is None and user has no admin mandate, auto-creates a personal mandate.
|
||||||
Creates FeatureAccess + FeatureAccessRole on the shared instance
|
|
||||||
in the root mandate. The user gets the feature's user-level role.
|
|
||||||
"""
|
"""
|
||||||
featureCode = data.featureCode
|
featureCode = data.featureCode
|
||||||
userId = str(context.user.id)
|
userId = str(context.user.id)
|
||||||
|
|
@ -226,82 +261,94 @@ def activateStoreFeature(
|
||||||
try:
|
try:
|
||||||
rootInterface = getRootInterface()
|
rootInterface = getRootInterface()
|
||||||
db = rootInterface.db
|
db = rootInterface.db
|
||||||
|
|
||||||
if not _checkStorePermission(context, featureCode):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail=f"No permission to activate feature '{featureCode}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
catalogService = getCatalogService()
|
catalogService = getCatalogService()
|
||||||
|
|
||||||
featureDef = catalogService.getFeatureDefinition(featureCode)
|
featureDef = catalogService.getFeatureDefinition(featureCode)
|
||||||
if not featureDef:
|
if not featureDef:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Feature '{featureCode}' not found")
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"Feature '{featureCode}' not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
rootMandateId = _getRootMandateId(db)
|
mandateId = data.mandateId
|
||||||
if not rootMandateId:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="Root mandate not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
sharedInstance = _findSharedInstance(db, rootMandateId, featureCode)
|
# Auto-create personal mandate if user has no admin mandates
|
||||||
if not sharedInstance:
|
if not mandateId:
|
||||||
raise HTTPException(
|
adminMandateIds = _getUserAdminMandateIds(db, userId)
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
if not adminMandateIds:
|
||||||
detail=f"Shared instance for '{featureCode}' not found in root mandate"
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
instanceId = sharedInstance.get("id")
|
# Verify user is admin in target mandate
|
||||||
|
if not _isUserAdminInMandate(db, userId, mandateId):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not admin in target mandate")
|
||||||
|
|
||||||
existingAccess = _getUserFeatureAccess(db, userId, instanceId)
|
# Check subscription capacity
|
||||||
if existingAccess:
|
from modules.datamodels.datamodelSubscription import MandateSubscription, BUILTIN_PLANS
|
||||||
raise HTTPException(
|
subs = db.getRecordset(MandateSubscription, recordFilter={"mandateId": mandateId})
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
if subs:
|
||||||
detail=f"Feature '{featureCode}' is already active"
|
sub = subs[0]
|
||||||
)
|
plan = BUILTIN_PLANS.get(sub.get("planKey"))
|
||||||
|
if plan and plan.maxFeatureInstances is not None:
|
||||||
|
currentInstances = db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId})
|
||||||
|
if len(currentInstances) >= plan.maxFeatureInstances:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||||
|
detail=f"Feature instance limit reached ({plan.maxFeatureInstances}). Upgrade your plan."
|
||||||
|
)
|
||||||
|
|
||||||
featureAccess = FeatureAccess(
|
# Create new FeatureInstance
|
||||||
userId=userId,
|
featureInterface = getFeatureInterface(db)
|
||||||
featureInstanceId=instanceId,
|
featureLabel = featureDef.get("label", {}).get("en", featureCode)
|
||||||
enabled=True
|
instance = featureInterface.createFeatureInstance(
|
||||||
|
featureCode=featureCode,
|
||||||
|
mandateId=mandateId,
|
||||||
|
label=featureLabel,
|
||||||
|
enabled=True,
|
||||||
|
copyTemplateRoles=True,
|
||||||
)
|
)
|
||||||
createdAccess = db.recordCreate(FeatureAccess, featureAccess.model_dump())
|
|
||||||
featureAccessId = createdAccess.get("id")
|
|
||||||
|
|
||||||
userRoleId = _findStoreUserRoleId(rootInterface, catalogService, instanceId, featureCode)
|
if not instance:
|
||||||
if not userRoleId:
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create feature instance")
|
||||||
db.recordDelete(FeatureAccess, featureAccessId)
|
|
||||||
logger.error(
|
|
||||||
f"Store activate rollback: no user role on instance {instanceId} for feature '{featureCode}'"
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=(
|
|
||||||
f"No '{featureCode}-user' (or equivalent) role found on the shared instance; "
|
|
||||||
"cannot grant store access. Contact an administrator."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
featureAccessRole = FeatureAccessRole(
|
instanceId = instance.get("id") if isinstance(instance, dict) else instance.id
|
||||||
featureAccessId=featureAccessId,
|
|
||||||
roleId=userRoleId
|
|
||||||
)
|
|
||||||
db.recordCreate(FeatureAccessRole, featureAccessRole.model_dump())
|
|
||||||
|
|
||||||
logger.info(
|
# Grant FeatureAccess with admin role
|
||||||
f"User {userId} activated store feature '{featureCode}' "
|
instanceRoles = db.getRecordset(Role, recordFilter={"featureInstanceId": instanceId})
|
||||||
f"(instance={instanceId}, role={userRoleId})"
|
adminRoleId = None
|
||||||
)
|
for ir in instanceRoles:
|
||||||
|
if "admin" in (ir.get("roleLabel") or "").lower():
|
||||||
|
adminRoleId = ir.get("id")
|
||||||
|
break
|
||||||
|
|
||||||
|
fa = FeatureAccess(userId=userId, featureInstanceId=instanceId, enabled=True)
|
||||||
|
createdFa = db.recordCreate(FeatureAccess, fa.model_dump())
|
||||||
|
if adminRoleId and createdFa:
|
||||||
|
far = FeatureAccessRole(featureAccessId=createdFa["id"], roleId=adminRoleId)
|
||||||
|
db.recordCreate(FeatureAccessRole, far.model_dump())
|
||||||
|
|
||||||
|
# Sync subscription quantity
|
||||||
|
try:
|
||||||
|
rootInterface._syncSubscriptionQuantity(mandateId)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to sync subscription quantity: {e}")
|
||||||
|
|
||||||
|
logger.info(f"User {userId} activated '{featureCode}' in mandate {mandateId} (instance={instanceId})")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"featureCode": featureCode,
|
"featureCode": featureCode,
|
||||||
|
"mandateId": mandateId,
|
||||||
"instanceId": instanceId,
|
"instanceId": instanceId,
|
||||||
"featureAccessId": featureAccessId,
|
|
||||||
"roleId": userRoleId,
|
|
||||||
"activated": True,
|
"activated": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -309,71 +356,67 @@ def activateStoreFeature(
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error activating store feature '{featureCode}': {e}")
|
logger.error(f"Error activating store feature '{featureCode}': {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to activate feature: {str(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/deactivate", response_model=Dict[str, Any])
|
@router.post("/deactivate", response_model=Dict[str, Any])
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
def deactivateStoreFeature(
|
def deactivateStoreFeature(
|
||||||
request: Request,
|
request: Request,
|
||||||
data: StoreActivateRequest,
|
data: StoreDeactivateRequest,
|
||||||
context: RequestContext = Depends(getRequestContext)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Deactivate a store feature for the current user.
|
Deactivate a store feature. Removes user's FeatureAccess.
|
||||||
|
Orphan Control: if last user deactivates, FeatureInstance is deleted.
|
||||||
Removes FeatureAccess (CASCADE deletes FeatureAccessRole).
|
|
||||||
User loses access immediately.
|
|
||||||
"""
|
"""
|
||||||
featureCode = data.featureCode
|
|
||||||
userId = str(context.user.id)
|
userId = str(context.user.id)
|
||||||
|
instanceId = data.instanceId
|
||||||
|
mandateId = data.mandateId
|
||||||
|
|
||||||
try:
|
try:
|
||||||
rootInterface = getRootInterface()
|
rootInterface = getRootInterface()
|
||||||
db = rootInterface.db
|
db = rootInterface.db
|
||||||
|
|
||||||
rootMandateId = _getRootMandateId(db)
|
# Verify instance exists in mandate
|
||||||
if not rootMandateId:
|
instances = db.getRecordset(FeatureInstance, recordFilter={"id": instanceId, "mandateId": mandateId})
|
||||||
raise HTTPException(
|
if not instances:
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feature instance not found in mandate")
|
||||||
detail="Root mandate not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
sharedInstance = _findSharedInstance(db, rootMandateId, featureCode)
|
# Find user's FeatureAccess
|
||||||
if not sharedInstance:
|
accesses = db.getRecordset(FeatureAccess, recordFilter={"userId": userId, "featureInstanceId": instanceId})
|
||||||
raise HTTPException(
|
if not accesses:
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No active access found")
|
||||||
detail=f"Shared instance for '{featureCode}' not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
instanceId = sharedInstance.get("id")
|
featureAccessId = accesses[0].get("id")
|
||||||
|
|
||||||
existingAccess = _getUserFeatureAccess(db, userId, instanceId)
|
|
||||||
if not existingAccess:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"Feature '{featureCode}' is not active"
|
|
||||||
)
|
|
||||||
|
|
||||||
featureAccessId = existingAccess.get("id")
|
|
||||||
db.recordDelete(FeatureAccess, featureAccessId)
|
db.recordDelete(FeatureAccess, featureAccessId)
|
||||||
|
|
||||||
logger.info(f"User {userId} deactivated store feature '{featureCode}' (instance={instanceId})")
|
# Orphan Control: check if any FeatureAccess remains
|
||||||
|
remainingAccesses = db.getRecordset(FeatureAccess, recordFilter={"featureInstanceId": instanceId})
|
||||||
|
instanceDeleted = False
|
||||||
|
if not remainingAccesses:
|
||||||
|
db.recordDelete(FeatureInstance, instanceId)
|
||||||
|
instanceDeleted = True
|
||||||
|
logger.info(f"Orphan Control: deleted instance {instanceId} (no remaining accesses)")
|
||||||
|
|
||||||
|
# Sync subscription quantity
|
||||||
|
try:
|
||||||
|
rootInterface._syncSubscriptionQuantity(mandateId)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to sync subscription quantity: {e}")
|
||||||
|
|
||||||
|
logger.info(f"User {userId} deactivated instance {instanceId} in mandate {mandateId} (deleted={instanceDeleted})")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"featureCode": featureCode,
|
"featureCode": data.featureCode,
|
||||||
|
"mandateId": mandateId,
|
||||||
"instanceId": instanceId,
|
"instanceId": instanceId,
|
||||||
"deactivated": True,
|
"deactivated": True,
|
||||||
|
"instanceDeleted": instanceDeleted,
|
||||||
}
|
}
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error deactivating store feature '{featureCode}': {e}")
|
logger.error(f"Error deactivating store feature: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to deactivate feature: {str(e)}"
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -363,6 +363,7 @@ class AgentService:
|
||||||
featureInstanceId=featureInstanceId,
|
featureInstanceId=featureInstanceId,
|
||||||
mandateId=mandateId,
|
mandateId=mandateId,
|
||||||
workflowHintItems=workflowHintItems,
|
workflowHintItems=workflowHintItems,
|
||||||
|
isSysAdmin=getattr(self.services.user, "isSysAdmin", False),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"RAG context not available: {e}")
|
logger.debug(f"RAG context not available: {e}")
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,9 @@ class AiService:
|
||||||
2. Balance & provider check before AI call
|
2. Balance & provider check before AI call
|
||||||
3. billingCallback on aiObjects: records one billing transaction per model call
|
3. billingCallback on aiObjects: records one billing transaction per model call
|
||||||
with exact provider + model name (set before AI call, invoked by _callWithModel)
|
with exact provider + model name (set before AI call, invoked by _callWithModel)
|
||||||
|
|
||||||
|
NEUTRALIZATION: If enabled, prompt text is neutralized before the AI call
|
||||||
|
and placeholders in the response are rehydrated afterwards.
|
||||||
"""
|
"""
|
||||||
await self.ensureAiObjectsInitialized()
|
await self.ensureAiObjectsInitialized()
|
||||||
|
|
||||||
|
|
@ -172,6 +175,11 @@ class AiService:
|
||||||
request.options = request.options.model_copy(update={'allowedProviders': effectiveProviders})
|
request.options = request.options.model_copy(update={'allowedProviders': effectiveProviders})
|
||||||
logger.debug(f"Effective allowedProviders for AI request: {effectiveProviders}")
|
logger.debug(f"Effective allowedProviders for AI request: {effectiveProviders}")
|
||||||
|
|
||||||
|
# Neutralize prompt if enabled (before AI call)
|
||||||
|
_wasNeutralized = False
|
||||||
|
if self._shouldNeutralize(request):
|
||||||
|
request, _wasNeutralized = self._neutralizeRequest(request)
|
||||||
|
|
||||||
# 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
|
||||||
# For parallel content parts (e.g., 200 MB doc), each model call creates its own transaction
|
# For parallel content parts (e.g., 200 MB doc), each model call creates its own transaction
|
||||||
|
|
@ -187,10 +195,18 @@ class AiService:
|
||||||
finally:
|
finally:
|
||||||
self.aiObjects.billingCallback = None
|
self.aiObjects.billingCallback = None
|
||||||
|
|
||||||
|
# Rehydrate neutralization placeholders in response
|
||||||
|
if _wasNeutralized and response and hasattr(response, 'content') and response.content:
|
||||||
|
response.content = self._rehydrateResponse(response.content)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
async def callAiStream(self, request: AiCallRequest):
|
async def callAiStream(self, request: AiCallRequest):
|
||||||
"""Streaming variant of callAi. Yields str deltas during generation, then final AiCallResponse."""
|
"""Streaming variant of callAi. Yields str deltas during generation, then final AiCallResponse.
|
||||||
|
|
||||||
|
NEUTRALIZATION: If enabled, prompt text is neutralized before streaming.
|
||||||
|
Rehydration happens on the final AiCallResponse (not on individual str deltas).
|
||||||
|
"""
|
||||||
await self.ensureAiObjectsInitialized()
|
await self.ensureAiObjectsInitialized()
|
||||||
self._preflightBillingCheck()
|
self._preflightBillingCheck()
|
||||||
await self._checkBillingBeforeAiCall()
|
await self._checkBillingBeforeAiCall()
|
||||||
|
|
@ -199,9 +215,17 @@ class AiService:
|
||||||
if effectiveProviders and request.options:
|
if effectiveProviders and request.options:
|
||||||
request.options = request.options.model_copy(update={'allowedProviders': effectiveProviders})
|
request.options = request.options.model_copy(update={'allowedProviders': effectiveProviders})
|
||||||
|
|
||||||
|
# Neutralize prompt if enabled (before streaming)
|
||||||
|
_wasNeutralized = False
|
||||||
|
if self._shouldNeutralize(request):
|
||||||
|
request, _wasNeutralized = self._neutralizeRequest(request)
|
||||||
|
|
||||||
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)
|
||||||
|
if _wasNeutralized and not isinstance(chunk, str) and hasattr(chunk, 'content') and chunk.content:
|
||||||
|
chunk.content = self._rehydrateResponse(chunk.content)
|
||||||
yield chunk
|
yield chunk
|
||||||
finally:
|
finally:
|
||||||
self.aiObjects.billingCallback = None
|
self.aiObjects.billingCallback = None
|
||||||
|
|
@ -511,6 +535,60 @@ detectedIntent-Werte:
|
||||||
|
|
||||||
return basePrompt
|
return basePrompt
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# NEUTRALIZATION: Centralized prompt neutralization / response rehydration
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def _shouldNeutralize(self, request: AiCallRequest) -> bool:
|
||||||
|
"""Check if this AI request should have neutralization applied.
|
||||||
|
Only applies to text prompts — not embeddings or image processing."""
|
||||||
|
try:
|
||||||
|
neutralSvc = self._get_service("neutralization")
|
||||||
|
if not neutralSvc:
|
||||||
|
return False
|
||||||
|
config = neutralSvc.getConfig() if hasattr(neutralSvc, 'getConfig') else None
|
||||||
|
if not config or not getattr(config, 'enabled', False):
|
||||||
|
return False
|
||||||
|
if not request.prompt and not request.messages:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _neutralizeRequest(self, request: AiCallRequest) -> Tuple[AiCallRequest, bool]:
|
||||||
|
"""Neutralize the prompt text in an AiCallRequest.
|
||||||
|
Returns (modifiedRequest, wasNeutralized)."""
|
||||||
|
try:
|
||||||
|
neutralSvc = self._get_service("neutralization")
|
||||||
|
if not neutralSvc or not hasattr(neutralSvc, 'processText'):
|
||||||
|
return request, False
|
||||||
|
|
||||||
|
if request.prompt:
|
||||||
|
result = neutralSvc.processText(request.prompt)
|
||||||
|
if result and result.get("neutralized_text"):
|
||||||
|
request.prompt = result["neutralized_text"]
|
||||||
|
logger.debug("Neutralized prompt in AiCallRequest")
|
||||||
|
return request, True
|
||||||
|
|
||||||
|
return request, False
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Request neutralization failed: {e}")
|
||||||
|
return request, False
|
||||||
|
|
||||||
|
def _rehydrateResponse(self, responseText: str) -> str:
|
||||||
|
"""Replace neutralization placeholders with original values in AI response."""
|
||||||
|
if not responseText:
|
||||||
|
return responseText
|
||||||
|
try:
|
||||||
|
neutralSvc = self._get_service("neutralization")
|
||||||
|
if not neutralSvc or not hasattr(neutralSvc, 'resolveText'):
|
||||||
|
return responseText
|
||||||
|
resolved = neutralSvc.resolveText(responseText)
|
||||||
|
return resolved if resolved else responseText
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Response rehydration failed: {e}")
|
||||||
|
return responseText
|
||||||
|
|
||||||
def _preflightBillingCheck(self) -> None:
|
def _preflightBillingCheck(self) -> None:
|
||||||
"""
|
"""
|
||||||
Pre-flight billing validation - like a 0 CHF credit card authorization check.
|
Pre-flight billing validation - like a 0 CHF credit card authorization check.
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,49 @@ 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
|
||||||
|
_shouldNeutralize = False
|
||||||
|
try:
|
||||||
|
from modules.datamodels.datamodelFiles import FileItem as _FileItem
|
||||||
|
_dbComponent = getattr(self._context, 'interfaceDbComponent', None)
|
||||||
|
_fileRecords = _dbComponent.getRecordset(_FileItem, recordFilter={"id": fileId}) if _dbComponent else []
|
||||||
|
if _fileRecords:
|
||||||
|
_fileRecord = _fileRecords[0]
|
||||||
|
_shouldNeutralize = (
|
||||||
|
_fileRecord.get("neutralize", False) if isinstance(_fileRecord, dict)
|
||||||
|
else getattr(_fileRecord, "neutralize", False)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if _shouldNeutralize and textObjects:
|
||||||
|
_neutralizedObjects = []
|
||||||
|
try:
|
||||||
|
_neutralSvc = self._getService("neutralization")
|
||||||
|
except Exception:
|
||||||
|
_neutralSvc = None
|
||||||
|
if _neutralSvc:
|
||||||
|
for _obj in textObjects:
|
||||||
|
_textContent = (_obj.get("data", "") or "").strip()
|
||||||
|
if not _textContent:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
_neutralResult = _neutralSvc.processText(
|
||||||
|
_textContent, userId=userId, featureInstanceId=featureInstanceId
|
||||||
|
)
|
||||||
|
if _neutralResult and _neutralResult.get("neutralized_text"):
|
||||||
|
_obj["data"] = _neutralResult["neutralized_text"]
|
||||||
|
_neutralizedObjects.append(_obj)
|
||||||
|
else:
|
||||||
|
logger.warning(f"Neutralization failed for file {fileId}, skipping text object (fail-safe)")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Neutralization error for file {fileId}: {e}, skipping text object (fail-safe)")
|
||||||
|
textObjects = _neutralizedObjects
|
||||||
|
else:
|
||||||
|
logger.warning(f"Neutralization required for file {fileId} but service unavailable, skipping text indexing")
|
||||||
|
textObjects = []
|
||||||
|
|
||||||
if textObjects:
|
if textObjects:
|
||||||
self._knowledgeDb.updateFileStatus(fileId, "embedding")
|
self._knowledgeDb.updateFileStatus(fileId, "embedding")
|
||||||
chunks = _chunkForEmbedding(textObjects, maxTokens=DEFAULT_CHUNK_TOKENS)
|
chunks = _chunkForEmbedding(textObjects, maxTokens=DEFAULT_CHUNK_TOKENS)
|
||||||
|
|
@ -155,6 +198,12 @@ class KnowledgeService:
|
||||||
|
|
||||||
self._knowledgeDb.updateFileStatus(fileId, "indexed")
|
self._knowledgeDb.updateFileStatus(fileId, "indexed")
|
||||||
index.status = "indexed"
|
index.status = "indexed"
|
||||||
|
if _shouldNeutralize:
|
||||||
|
try:
|
||||||
|
index.neutralizationStatus = "completed"
|
||||||
|
self._knowledgeDb.upsertFileContentIndex(index)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Could not set neutralizationStatus for file {fileId}: {e}")
|
||||||
logger.info(f"Indexed file {fileId} ({fileName}): {len(contentObjects)} objects, {len(textObjects)} text chunks")
|
logger.info(f"Indexed file {fileId} ({fileName}): {len(contentObjects)} objects, {len(textObjects)} text chunks")
|
||||||
return index
|
return index
|
||||||
|
|
||||||
|
|
@ -171,6 +220,7 @@ class KnowledgeService:
|
||||||
mandateId: str = "",
|
mandateId: str = "",
|
||||||
contextBudget: int = DEFAULT_CONTEXT_BUDGET,
|
contextBudget: int = DEFAULT_CONTEXT_BUDGET,
|
||||||
workflowHintItems: List[Dict[str, Any]] = None,
|
workflowHintItems: List[Dict[str, Any]] = None,
|
||||||
|
isSysAdmin: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Build RAG context for an agent round by searching all layers.
|
"""Build RAG context for an agent round by searching all layers.
|
||||||
|
|
||||||
|
|
@ -217,13 +267,15 @@ class KnowledgeService:
|
||||||
maxChars=2000,
|
maxChars=2000,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Layer 1: Instance Layer (user's own documents, highest priority)
|
# Layer 1: Scope-based document search (personal + instance + mandate + global)
|
||||||
instanceChunks = self._knowledgeDb.semanticSearch(
|
instanceChunks = self._knowledgeDb.semanticSearch(
|
||||||
queryVector=queryVector,
|
queryVector=queryVector,
|
||||||
userId=userId,
|
userId=userId,
|
||||||
featureInstanceId=featureInstanceId,
|
featureInstanceId=featureInstanceId,
|
||||||
|
mandateId=mandateId,
|
||||||
limit=15,
|
limit=15,
|
||||||
minScore=0.65,
|
minScore=0.65,
|
||||||
|
isSysAdmin=isSysAdmin,
|
||||||
)
|
)
|
||||||
if instanceChunks:
|
if instanceChunks:
|
||||||
builder.add(priority=1, label="Relevant Documents", items=instanceChunks, maxChars=4000)
|
builder.add(priority=1, label="Relevant Documents", items=instanceChunks, maxChars=4000)
|
||||||
|
|
@ -271,6 +323,7 @@ class KnowledgeService:
|
||||||
isShared=True,
|
isShared=True,
|
||||||
limit=10,
|
limit=10,
|
||||||
minScore=0.7,
|
minScore=0.7,
|
||||||
|
isSysAdmin=isSysAdmin,
|
||||||
)
|
)
|
||||||
if sharedChunks:
|
if sharedChunks:
|
||||||
builder.add(priority=4, label="Shared Knowledge", items=sharedChunks, maxChars=2000)
|
builder.add(priority=4, label="Shared Knowledge", items=sharedChunks, maxChars=2000)
|
||||||
|
|
|
||||||
|
|
@ -172,13 +172,13 @@ async def neutralizeData(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
)
|
)
|
||||||
neutralizedParts.append(neutralizedPart)
|
neutralizedParts.append(neutralizedPart)
|
||||||
else:
|
else:
|
||||||
# Neutralization failed, use original part
|
# Fail-Safe: neutralization incomplete, skip this part
|
||||||
logger.warning(f"Neutralization did not return neutralized_text for part {part.id}")
|
logger.warning(f"Fail-Safe: Neutralization incomplete for part {part.id}, SKIPPING (not passing original)")
|
||||||
neutralizedParts.append(part)
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error neutralizing part {part.id}: {str(e)}")
|
logger.error(f"Fail-Safe: Error neutralizing part {part.id}, SKIPPING document (not passing original): {str(e)}")
|
||||||
# On error, use original part
|
# Fail-Safe: do NOT pass original data to AI
|
||||||
neutralizedParts.append(part)
|
continue
|
||||||
else:
|
else:
|
||||||
# No data to neutralize, keep original part
|
# No data to neutralize, keep original part
|
||||||
neutralizedParts.append(part)
|
neutralizedParts.append(part)
|
||||||
|
|
|
||||||
|
|
@ -351,7 +351,13 @@ class WorkflowManager:
|
||||||
if documents:
|
if documents:
|
||||||
for i, doc in enumerate(documents, 1):
|
for i, doc in enumerate(documents, 1):
|
||||||
docListText += f"\n{i}. {doc.fileName} ({doc.mimeType}, {doc.fileSize} bytes)"
|
docListText += f"\n{i}. {doc.fileName} ({doc.mimeType}, {doc.fileSize} bytes)"
|
||||||
|
|
||||||
|
_userId = getattr(getattr(self.services, 'user', None), 'id', '') or ''
|
||||||
|
_featureInstanceId = getattr(self.services, 'featureInstanceId', '') or ''
|
||||||
|
_promptForAnalysis, _wasNeutralized, _mappingId = await self._neutralizePromptIfRequired(
|
||||||
|
userPrompt, userId=_userId, featureInstanceId=_featureInstanceId
|
||||||
|
)
|
||||||
|
|
||||||
analysisPrompt = f"""You are an input analyzer. From the user's message, perform ALL of the following in one pass:
|
analysisPrompt = f"""You are an input analyzer. From the user's message, perform ALL of the following in one pass:
|
||||||
|
|
||||||
1. detectedLanguage: Detect ISO 639-1 language code (e.g., de, en, fr, it)
|
1. detectedLanguage: Detect ISO 639-1 language code (e.g., de, en, fr, it)
|
||||||
|
|
@ -401,7 +407,7 @@ Return ONLY JSON (no markdown) with this exact structure:
|
||||||
The following is the user's original input message. Analyze intent, normalize the request, and determine complexity:
|
The following is the user's original input message. Analyze intent, normalize the request, and determine complexity:
|
||||||
|
|
||||||
################ USER INPUT START #################
|
################ USER INPUT START #################
|
||||||
{userPrompt.replace('{', '{{').replace('}', '}}') if userPrompt else ''}
|
{_promptForAnalysis.replace('{', '{{').replace('}', '}}') if _promptForAnalysis else ''}
|
||||||
################ USER INPUT FINISH #################
|
################ USER INPUT FINISH #################
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -419,6 +425,12 @@ The following is the user's original input message. Analyze intent, normalize th
|
||||||
jsonEnd = aiResponse.rfind('}') + 1 if aiResponse else 0
|
jsonEnd = aiResponse.rfind('}') + 1 if aiResponse else 0
|
||||||
if jsonStart != -1 and jsonEnd > jsonStart:
|
if jsonStart != -1 and jsonEnd > jsonStart:
|
||||||
result = json.loads(aiResponse[jsonStart:jsonEnd])
|
result = json.loads(aiResponse[jsonStart:jsonEnd])
|
||||||
|
if _wasNeutralized:
|
||||||
|
for _field in ('normalizedRequest', 'intent', 'workflowName'):
|
||||||
|
if _field in result and result[_field]:
|
||||||
|
result[_field] = await self._rehydrateResponseIfNeeded(
|
||||||
|
result[_field], True, userId=_userId, featureInstanceId=_featureInstanceId
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
else:
|
else:
|
||||||
logger.warning("Could not parse combined analysis response, using defaults")
|
logger.warning("Could not parse combined analysis response, using defaults")
|
||||||
|
|
@ -1353,6 +1365,38 @@ The following is the user's original input message. Analyze intent, normalize th
|
||||||
"""Set user language for the service center"""
|
"""Set user language for the service center"""
|
||||||
self.services.user.language = language
|
self.services.user.language = language
|
||||||
|
|
||||||
|
async def _neutralizePromptIfRequired(self, prompt: str, userId: str, featureInstanceId: str) -> tuple:
|
||||||
|
"""Neutralize prompt text if the workflow context requires it.
|
||||||
|
Returns (processedPrompt, wasNeutralized, mappingId)."""
|
||||||
|
try:
|
||||||
|
_neutralSvc = getattr(self.services, 'neutralization', None)
|
||||||
|
if not _neutralSvc:
|
||||||
|
return prompt, False, None
|
||||||
|
_config = _neutralSvc.getConfig() if hasattr(_neutralSvc, 'getConfig') else None
|
||||||
|
if not _config or not getattr(_config, 'enabled', False):
|
||||||
|
return prompt, False, None
|
||||||
|
_result = _neutralSvc.processText(prompt, userId=userId, featureInstanceId=featureInstanceId)
|
||||||
|
if _result and _result.get("neutralized_text"):
|
||||||
|
return _result["neutralized_text"], True, _result.get("mappingId")
|
||||||
|
return prompt, False, None
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Prompt neutralization failed: {e}")
|
||||||
|
return prompt, False, None
|
||||||
|
|
||||||
|
async def _rehydrateResponseIfNeeded(self, response: str, wasNeutralized: bool, userId: str, featureInstanceId: str) -> str:
|
||||||
|
"""Replace placeholders in AI response with original values."""
|
||||||
|
if not wasNeutralized or not response:
|
||||||
|
return response
|
||||||
|
try:
|
||||||
|
_neutralSvc = getattr(self.services, 'neutralization', None)
|
||||||
|
if not _neutralSvc:
|
||||||
|
return response
|
||||||
|
_rehydrated = _neutralSvc.resolveText(response, userId=userId, featureInstanceId=featureInstanceId)
|
||||||
|
return _rehydrated if _rehydrated else response
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Response re-hydration failed: {e}")
|
||||||
|
return response
|
||||||
|
|
||||||
async def _neutralizeContentIfEnabled(self, contentBytes: bytes, mimeType: str) -> bytes:
|
async def _neutralizeContentIfEnabled(self, contentBytes: bytes, mimeType: str) -> bytes:
|
||||||
"""Neutralize content if neutralization is enabled in user settings"""
|
"""Neutralize content if neutralization is enabled in user settings"""
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
323
tests/test_phase123_basic.py
Normal file
323
tests/test_phase123_basic.py
Normal file
|
|
@ -0,0 +1,323 @@
|
||||||
|
"""
|
||||||
|
Basic verification tests for Phase 1-3 implementation.
|
||||||
|
Run with: python tests/test_phase123_basic.py
|
||||||
|
Requires: gateway running on localhost:8000
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("PHASE 1-3 BASIC VERIFICATION")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
passes = []
|
||||||
|
|
||||||
|
def _check(label, condition, detail=""):
|
||||||
|
if condition:
|
||||||
|
passes.append(label)
|
||||||
|
print(f" [PASS] {label}")
|
||||||
|
else:
|
||||||
|
errors.append(f"{label}: {detail}")
|
||||||
|
print(f" [FAIL] {label} — {detail}")
|
||||||
|
|
||||||
|
# ── Phase 1: Data Models ──────────────────────────────────────────────────────
|
||||||
|
print("\n--- Phase 1: Data Models ---")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from modules.datamodels.datamodelUam import Mandate, MandateType
|
||||||
|
_check("MandateType Enum exists", hasattr(MandateType, "SYSTEM"))
|
||||||
|
_check("MandateType values", set(MandateType) == {MandateType.SYSTEM, MandateType.PERSONAL, MandateType.COMPANY})
|
||||||
|
m = Mandate(name="test", label="test", mandateType="personal")
|
||||||
|
_check("Mandate has mandateType field", hasattr(m, "mandateType"))
|
||||||
|
_check("Mandate mandateType coercion", m.mandateType == MandateType.PERSONAL)
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Phase 1 DataModel: {e}")
|
||||||
|
print(f" [FAIL] Phase 1 DataModel import: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from modules.datamodels.datamodelSubscription import SubscriptionStatusEnum, BUILTIN_PLANS, SubscriptionPlan
|
||||||
|
_check("PENDING status exists", hasattr(SubscriptionStatusEnum, "PENDING"))
|
||||||
|
_check("BUILTIN_PLANS has TRIAL_7D", "TRIAL_7D" in BUILTIN_PLANS)
|
||||||
|
trial = BUILTIN_PLANS["TRIAL_7D"]
|
||||||
|
_check("TRIAL_7D has maxDataVolumeMB", hasattr(trial, "maxDataVolumeMB"))
|
||||||
|
_check("TRIAL_7D maxDataVolumeMB=500", trial.maxDataVolumeMB == 500)
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Phase 1 Subscription: {e}")
|
||||||
|
print(f" [FAIL] Phase 1 Subscription: {e}")
|
||||||
|
|
||||||
|
# ── Phase 2: Scope Fields ─────────────────────────────────────────────────────
|
||||||
|
print("\n--- Phase 2: Scope Fields on Models ---")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from modules.datamodels.datamodelFiles import FileItem
|
||||||
|
fi = FileItem(fileName="test.txt", mimeType="text/plain", fileHash="abc", fileSize=100)
|
||||||
|
_check("FileItem has scope field", hasattr(fi, "scope"))
|
||||||
|
_check("FileItem scope default=personal", fi.scope == "personal")
|
||||||
|
_check("FileItem has neutralize field", hasattr(fi, "neutralize"))
|
||||||
|
_check("FileItem neutralize default=False", fi.neutralize == False)
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Phase 2 FileItem: {e}")
|
||||||
|
print(f" [FAIL] Phase 2 FileItem: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from modules.datamodels.datamodelDataSource import DataSource
|
||||||
|
ds = DataSource(connectionId="c1", sourceType="sharepoint", path="/test", label="Test")
|
||||||
|
_check("DataSource has scope field", hasattr(ds, "scope"))
|
||||||
|
_check("DataSource scope default=personal", ds.scope == "personal")
|
||||||
|
_check("DataSource has neutralize field", hasattr(ds, "neutralize"))
|
||||||
|
_check("DataSource neutralize default=False", ds.neutralize == False)
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Phase 2 DataSource: {e}")
|
||||||
|
print(f" [FAIL] Phase 2 DataSource: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from modules.datamodels.datamodelKnowledge import FileContentIndex
|
||||||
|
fci = FileContentIndex(userId="u1", fileName="test.txt", mimeType="text/plain")
|
||||||
|
_check("FileContentIndex has scope field", hasattr(fci, "scope"))
|
||||||
|
_check("FileContentIndex scope default=personal", fci.scope == "personal")
|
||||||
|
_check("FileContentIndex has neutralizationStatus", hasattr(fci, "neutralizationStatus"))
|
||||||
|
_check("FileContentIndex neutralizationStatus default=None", fci.neutralizationStatus is None)
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Phase 2 FileContentIndex: {e}")
|
||||||
|
print(f" [FAIL] Phase 2 FileContentIndex: {e}")
|
||||||
|
|
||||||
|
# ── Phase 2: RAG Scope Filtering ──────────────────────────────────────────────
|
||||||
|
print("\n--- Phase 2: RAG Scope Logic ---")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from modules.interfaces.interfaceDbKnowledge import KnowledgeObjects
|
||||||
|
_check("KnowledgeObjects has _getScopedFileIds", hasattr(KnowledgeObjects, "_getScopedFileIds"))
|
||||||
|
_check("KnowledgeObjects has _buildScopeFilter", hasattr(KnowledgeObjects, "_buildScopeFilter"))
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
sig = inspect.signature(KnowledgeObjects._getScopedFileIds)
|
||||||
|
params = list(sig.parameters.keys())
|
||||||
|
_check("_getScopedFileIds has isSysAdmin param", "isSysAdmin" in params)
|
||||||
|
|
||||||
|
sig2 = inspect.signature(KnowledgeObjects.semanticSearch)
|
||||||
|
params2 = list(sig2.parameters.keys())
|
||||||
|
_check("semanticSearch has scope param", "scope" in params2)
|
||||||
|
_check("semanticSearch has isSysAdmin param", "isSysAdmin" in params2)
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Phase 2 RAG: {e}")
|
||||||
|
print(f" [FAIL] Phase 2 RAG: {e}")
|
||||||
|
|
||||||
|
# ── Phase 3: Neutralization Methods ───────────────────────────────────────────
|
||||||
|
print("\n--- Phase 3: Neutralization Integration ---")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from modules.workflows.workflowManager import WorkflowManager
|
||||||
|
_check("WorkflowManager has _neutralizePromptIfRequired", hasattr(WorkflowManager, "_neutralizePromptIfRequired"))
|
||||||
|
_check("WorkflowManager has _rehydrateResponseIfNeeded", hasattr(WorkflowManager, "_rehydrateResponseIfNeeded"))
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
sig_n = inspect.signature(WorkflowManager._neutralizePromptIfRequired)
|
||||||
|
_check("_neutralizePromptIfRequired is async", inspect.iscoroutinefunction(WorkflowManager._neutralizePromptIfRequired))
|
||||||
|
|
||||||
|
sig_r = inspect.signature(WorkflowManager._rehydrateResponseIfNeeded)
|
||||||
|
_check("_rehydrateResponseIfNeeded is async", inspect.iscoroutinefunction(WorkflowManager._rehydrateResponseIfNeeded))
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Phase 3 WorkflowManager: {e}")
|
||||||
|
print(f" [FAIL] Phase 3 WorkflowManager: {e}")
|
||||||
|
|
||||||
|
# ── Phase 3: Fail-Safe Logic ──────────────────────────────────────────────────
|
||||||
|
print("\n--- Phase 3: Fail-Safe Logic ---")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import ast
|
||||||
|
with open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||||
|
"modules", "workflows", "methods", "methodContext", "actions", "neutralizeData.py"), "r") as f:
|
||||||
|
source = f.read()
|
||||||
|
_check("neutralizeData.py has 'SKIPPING' fail-safe", "SKIPPING" in source)
|
||||||
|
_check("neutralizeData.py has 'do NOT pass original' comment", "do NOT pass original" in source.lower() or "not passing original" in source.lower())
|
||||||
|
_check("neutralizeData.py uses continue for skip", "continue" in source)
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Phase 3 Fail-Safe: {e}")
|
||||||
|
print(f" [FAIL] Phase 3 Fail-Safe: {e}")
|
||||||
|
|
||||||
|
# ── Phase 2: Route Endpoints ──────────────────────────────────────────────────
|
||||||
|
print("\n--- Phase 2: API Endpoints ---")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import ast
|
||||||
|
with open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||||
|
"modules", "routes", "routeDataFiles.py"), "r") as f:
|
||||||
|
source = f.read()
|
||||||
|
_check("routeDataFiles has PATCH scope endpoint", "updateFileScope" in source)
|
||||||
|
_check("routeDataFiles has PATCH neutralize endpoint", "updateFileNeutralize" in source)
|
||||||
|
_check("routeDataFiles checks global sysAdmin", "hasSysAdminRole" in source or "sysadmin" in source.lower())
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Phase 2 Routes: {e}")
|
||||||
|
print(f" [FAIL] Phase 2 Routes: {e}")
|
||||||
|
|
||||||
|
# ── Phase 1: Store Endpoints ──────────────────────────────────────────────────
|
||||||
|
print("\n--- Phase 1: Store Endpoints ---")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||||
|
"modules", "routes", "routeStore.py"), "r") as f:
|
||||||
|
source = f.read()
|
||||||
|
_check("routeStore has listUserMandates", "listUserMandates" in source or "list_user_mandates" in source)
|
||||||
|
_check("routeStore has getSubscriptionInfo", "getSubscriptionInfo" in source or "get_subscription_info" in source)
|
||||||
|
_check("routeStore has orphan control", "orphan" in source.lower() or "last" in source.lower())
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Phase 1 Store: {e}")
|
||||||
|
print(f" [FAIL] Phase 1 Store: {e}")
|
||||||
|
|
||||||
|
# ── Phase 1: Provisioning ─────────────────────────────────────────────────────
|
||||||
|
print("\n--- Phase 1: Provisioning ---")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||||
|
"modules", "interfaces", "interfaceDbApp.py"), "r") as f:
|
||||||
|
source = f.read()
|
||||||
|
_check("interfaceDbApp has _provisionMandateForUser", "_provisionMandateForUser" in source)
|
||||||
|
_check("interfaceDbApp has _activatePendingSubscriptions", "_activatePendingSubscriptions" in source)
|
||||||
|
_check("interfaceDbApp has deleteMandate cascade", "deleteMandate" in source and "cascade" in source.lower())
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Phase 1 Provisioning: {e}")
|
||||||
|
print(f" [FAIL] Phase 1 Provisioning: {e}")
|
||||||
|
|
||||||
|
# ── Phase 1: Registration Routes ──────────────────────────────────────────────
|
||||||
|
print("\n--- Phase 1: Registration ---")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||||
|
"modules", "routes", "routeSecurityLocal.py"), "r") as f:
|
||||||
|
source = f.read()
|
||||||
|
_check("routeSecurityLocal has registrationType", "registrationType" in source)
|
||||||
|
_check("routeSecurityLocal has companyName", "companyName" in source)
|
||||||
|
_check("routeSecurityLocal has onboarding endpoint", "onboarding" in source)
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Phase 1 Registration: {e}")
|
||||||
|
print(f" [FAIL] Phase 1 Registration: {e}")
|
||||||
|
|
||||||
|
# ── Phase 1: Migration ────────────────────────────────────────────────────────
|
||||||
|
print("\n--- Phase 1: Migration ---")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||||
|
"modules", "migration", "migrateRootUsers.py"), "r") as f:
|
||||||
|
source = f.read()
|
||||||
|
_check("Migration script exists", True)
|
||||||
|
_check("Migration has _isMigrationCompleted", "_isMigrationCompleted" in source)
|
||||||
|
_check("Migration has migrateRootUsers", "migrateRootUsers" in source)
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Phase 1 Migration: {e}")
|
||||||
|
print(f" [FAIL] Phase 1 Migration: {e}")
|
||||||
|
|
||||||
|
# ── Fix 1: OnboardingWizard Integration ────────────────────────────────────────
|
||||||
|
print("\n--- Fix 1: OnboardingWizard Integration ---")
|
||||||
|
|
||||||
|
try:
|
||||||
|
loginPath = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||||
|
"..", "frontend_nyla", "src", "pages", "Login.tsx")
|
||||||
|
with open(loginPath, "r", encoding="utf-8") as f:
|
||||||
|
source = f.read()
|
||||||
|
_check("Login.tsx imports OnboardingWizard", "OnboardingWizard" in source)
|
||||||
|
_check("Login.tsx has showOnboardingWizard state", "showOnboardingWizard" in source)
|
||||||
|
_check("Login.tsx checks isNewUser", "isNewUser" in source)
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Fix 1: {e}")
|
||||||
|
print(f" [FAIL] Fix 1: {e}")
|
||||||
|
|
||||||
|
# ── Fix 2: CommCoach UDB Integration ──────────────────────────────────────────
|
||||||
|
print("\n--- Fix 2: CommCoach UDB Integration ---")
|
||||||
|
|
||||||
|
try:
|
||||||
|
dossierPath = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||||
|
"..", "frontend_nyla", "src", "pages", "views", "commcoach", "CommcoachDossierView.tsx")
|
||||||
|
with open(dossierPath, "r", encoding="utf-8") as f:
|
||||||
|
source = f.read()
|
||||||
|
_check("CommCoach imports UnifiedDataBar", "UnifiedDataBar" in source)
|
||||||
|
_check("CommCoach imports FilesTab", "FilesTab" in source)
|
||||||
|
_check("CommCoach no longer imports getDocumentsApi", "getDocumentsApi" not in source)
|
||||||
|
_check("CommCoach has UDB sidebar", "udbSidebar" in source or "UnifiedDataBar" in source)
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Fix 2: {e}")
|
||||||
|
print(f" [FAIL] Fix 2: {e}")
|
||||||
|
|
||||||
|
# ── Fix 3: Neutralization Backend Endpoints ───────────────────────────────────
|
||||||
|
print("\n--- Fix 3: Neutralization Backend Endpoints ---")
|
||||||
|
|
||||||
|
try:
|
||||||
|
routePath = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||||
|
"modules", "features", "neutralization", "routeFeatureNeutralizer.py")
|
||||||
|
with open(routePath, "r") as f:
|
||||||
|
source = f.read()
|
||||||
|
_check("Neutralization has deleteAttribute endpoint", "deleteAttribute" in source or "delete_attribute" in source)
|
||||||
|
_check("Neutralization has retrigger endpoint", "retrigger" in source)
|
||||||
|
_check("Neutralization has single attribute delete", "single" in source or "attributeId" in source)
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Fix 3: {e}")
|
||||||
|
print(f" [FAIL] Fix 3: {e}")
|
||||||
|
|
||||||
|
# ── Fix 4: Central AI Neutralization ──────────────────────────────────────────
|
||||||
|
print("\n--- Fix 4: Central AI Neutralization ---")
|
||||||
|
|
||||||
|
try:
|
||||||
|
aiPath = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||||
|
"modules", "serviceCenter", "services", "serviceAi", "mainServiceAi.py")
|
||||||
|
with open(aiPath, "r") as f:
|
||||||
|
source = f.read()
|
||||||
|
_check("AiService has _shouldNeutralize", "_shouldNeutralize" in source)
|
||||||
|
_check("AiService has _neutralizeRequest", "_neutralizeRequest" in source)
|
||||||
|
_check("AiService has _rehydrateResponse", "_rehydrateResponse" in source)
|
||||||
|
_check("callAi uses neutralization", "_shouldNeutralize" in source and "_neutralizeRequest" in source)
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Fix 4: {e}")
|
||||||
|
print(f" [FAIL] Fix 4: {e}")
|
||||||
|
|
||||||
|
# ── Fix 5: Voice Settings User Level ──────────────────────────────────────────
|
||||||
|
print("\n--- Fix 5: Voice Settings User Level ---")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from modules.datamodels.datamodelUam import UserVoicePreferences
|
||||||
|
uvp = UserVoicePreferences(userId="u1")
|
||||||
|
_check("UserVoicePreferences model exists", True)
|
||||||
|
_check("UserVoicePreferences has sttLanguage", hasattr(uvp, "sttLanguage"))
|
||||||
|
_check("UserVoicePreferences default sttLanguage=de-DE", uvp.sttLanguage == "de-DE")
|
||||||
|
_check("UserVoicePreferences has ttsVoice", hasattr(uvp, "ttsVoice"))
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Fix 5: {e}")
|
||||||
|
print(f" [FAIL] Fix 5: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||||
|
"modules", "routes", "routeSecurityLocal.py"), "r") as f:
|
||||||
|
source = f.read()
|
||||||
|
_check("Voice preferences GET endpoint", "voice-preferences" in source and "getVoicePreferences" in source)
|
||||||
|
_check("Voice preferences PUT endpoint", "updateVoicePreferences" in source)
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Fix 5 Routes: {e}")
|
||||||
|
print(f" [FAIL] Fix 5 Routes: {e}")
|
||||||
|
|
||||||
|
# ── Fix 6: RAG mandate-wide scope ─────────────────────────────────────────────
|
||||||
|
print("\n--- Fix 6: RAG mandate-wide scope ---")
|
||||||
|
|
||||||
|
try:
|
||||||
|
knowledgePath = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||||
|
"modules", "serviceCenter", "services", "serviceKnowledge", "mainServiceKnowledge.py")
|
||||||
|
with open(knowledgePath, "r") as f:
|
||||||
|
source = f.read()
|
||||||
|
_check("buildAgentContext passes mandateId to semanticSearch", "mandateId=mandateId" in source)
|
||||||
|
_check("buildAgentContext has isSysAdmin param", "isSysAdmin" in source)
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Fix 6: {e}")
|
||||||
|
print(f" [FAIL] Fix 6: {e}")
|
||||||
|
|
||||||
|
# ── Summary ───────────────────────────────────────────────────────────────────
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(f"RESULTS: {len(passes)} passed, {len(errors)} failed")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
print("\nFAILURES:")
|
||||||
|
for e in errors:
|
||||||
|
print(f" - {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
print("\nALL CHECKS PASSED!")
|
||||||
|
sys.exit(0)
|
||||||
Loading…
Reference in a new issue