fixed formgenerator , trustee, sort and filter
This commit is contained in:
parent
19f9aa3674
commit
35209f7f80
42 changed files with 1913 additions and 2040 deletions
|
|
@ -121,6 +121,21 @@ def _get_model_fields(model_class) -> Dict[str, str]:
|
||||||
return fields
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
def _get_fk_sort_meta(model_class) -> Dict[str, Dict[str, str]]:
|
||||||
|
"""Map FK field name -> {model, labelField} from json_schema_extra (fk_model + frontend_fk_display_field)."""
|
||||||
|
result: Dict[str, Dict[str, str]] = {}
|
||||||
|
for name, field_info in model_class.model_fields.items():
|
||||||
|
extra = field_info.json_schema_extra
|
||||||
|
if not extra or not isinstance(extra, dict):
|
||||||
|
continue
|
||||||
|
fk_model = extra.get("fk_model")
|
||||||
|
label_field = extra.get("frontend_fk_display_field")
|
||||||
|
if fk_model and label_field:
|
||||||
|
result[name] = {"model": str(fk_model), "labelField": str(label_field)}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _parseRecordFields(record: Dict[str, Any], fields: Dict[str, str], context: str = "") -> None:
|
def _parseRecordFields(record: Dict[str, Any], fields: Dict[str, str], context: str = "") -> None:
|
||||||
"""Parse record fields in-place: numeric typing, vector parsing, JSONB deserialization."""
|
"""Parse record fields in-place: numeric typing, vector parsing, JSONB deserialization."""
|
||||||
import json as _json
|
import json as _json
|
||||||
|
|
@ -1011,6 +1026,7 @@ class DatabaseConnector:
|
||||||
"""
|
"""
|
||||||
fields = _get_model_fields(model_class)
|
fields = _get_model_fields(model_class)
|
||||||
validColumns = set(fields.keys())
|
validColumns = set(fields.keys())
|
||||||
|
|
||||||
where_parts: List[str] = []
|
where_parts: List[str] = []
|
||||||
values: List[Any] = []
|
values: List[Any] = []
|
||||||
|
|
||||||
|
|
@ -1160,10 +1176,10 @@ class DatabaseConnector:
|
||||||
|
|
||||||
with self.connection.cursor() as cursor:
|
with self.connection.cursor() as cursor:
|
||||||
countSql = f'SELECT COUNT(*) FROM "{table}"{where_clause}'
|
countSql = f'SELECT COUNT(*) FROM "{table}"{where_clause}'
|
||||||
|
dataSql = f'SELECT * FROM "{table}"{where_clause}{order_clause}{limit_clause}'
|
||||||
cursor.execute(countSql, count_values)
|
cursor.execute(countSql, count_values)
|
||||||
totalItems = cursor.fetchone()["count"]
|
totalItems = cursor.fetchone()["count"]
|
||||||
|
|
||||||
dataSql = f'SELECT * FROM "{table}"{where_clause}{order_clause}{limit_clause}'
|
|
||||||
cursor.execute(dataSql, values)
|
cursor.execute(dataSql, values)
|
||||||
records = [dict(row) for row in cursor.fetchall()]
|
records = [dict(row) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
|
@ -1218,10 +1234,11 @@ class DatabaseConnector:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
if pagination:
|
if pagination:
|
||||||
if pagination.filters and column in pagination.filters:
|
|
||||||
import copy
|
import copy
|
||||||
pagination = copy.deepcopy(pagination)
|
pagination = copy.deepcopy(pagination)
|
||||||
|
if pagination.filters and column in pagination.filters:
|
||||||
pagination.filters.pop(column, None)
|
pagination.filters.pop(column, None)
|
||||||
|
pagination.sort = []
|
||||||
|
|
||||||
where_clause, _, _, values, _ = \
|
where_clause, _, _, values, _ = \
|
||||||
self._buildPaginationClauses(model_class, pagination, recordFilter)
|
self._buildPaginationClauses(model_class, pagination, recordFilter)
|
||||||
|
|
|
||||||
|
|
@ -196,12 +196,13 @@ class ActionResult(BaseModel):
|
||||||
success: bool = Field(description="Whether execution succeeded", json_schema_extra={"label": "Erfolg"})
|
success: bool = Field(description="Whether execution succeeded", json_schema_extra={"label": "Erfolg"})
|
||||||
error: Optional[str] = Field(None, description="Error message if failed", json_schema_extra={"label": "Fehler"})
|
error: Optional[str] = Field(None, description="Error message if failed", json_schema_extra={"label": "Fehler"})
|
||||||
documents: List[ActionDocument] = Field(default_factory=list, description="Document outputs", json_schema_extra={"label": "Dokumente"})
|
documents: List[ActionDocument] = Field(default_factory=list, description="Document outputs", json_schema_extra={"label": "Dokumente"})
|
||||||
|
data: Optional[Dict[str, Any]] = Field(None, description="Structured result data accessible via DataRef", json_schema_extra={"label": "Daten"})
|
||||||
resultLabel: Optional[str] = Field(None,
|
resultLabel: Optional[str] = Field(None,
|
||||||
description="Label for document routing (set by action handler, not by action methods)", json_schema_extra={"label": "Ergebnis-Label"})
|
description="Label for document routing (set by action handler, not by action methods)", json_schema_extra={"label": "Ergebnis-Label"})
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def isSuccess(cls, documents: List[ActionDocument] = None) -> "ActionResult":
|
def isSuccess(cls, documents: List[ActionDocument] = None, data: Dict[str, Any] = None) -> "ActionResult":
|
||||||
return cls(success=True, documents=documents or [])
|
return cls(success=True, documents=documents or [], data=data)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def isFailure(
|
def isFailure(
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,28 @@ class FileItem(PowerOnModel):
|
||||||
mandateId: Optional[str] = Field(
|
mandateId: Optional[str] = Field(
|
||||||
default="",
|
default="",
|
||||||
description="ID of the mandate this file belongs to",
|
description="ID of the mandate this file belongs to",
|
||||||
json_schema_extra={"label": "Mandant", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "frontend_fk_source": "/api/mandates/", "frontend_fk_display_field": "label"},
|
json_schema_extra={
|
||||||
|
"label": "Mandant",
|
||||||
|
"frontend_type": "text",
|
||||||
|
"frontend_readonly": True,
|
||||||
|
"frontend_required": False,
|
||||||
|
"frontend_fk_source": "/api/mandates/",
|
||||||
|
"frontend_fk_display_field": "label",
|
||||||
|
"fk_model": "Mandate",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
featureInstanceId: Optional[str] = Field(
|
featureInstanceId: Optional[str] = Field(
|
||||||
default="",
|
default="",
|
||||||
description="ID of the feature instance this file belongs to",
|
description="ID of the feature instance this file belongs to",
|
||||||
json_schema_extra={"label": "Feature-Instanz", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "frontend_fk_source": "/api/features/instances", "frontend_fk_display_field": "label"},
|
json_schema_extra={
|
||||||
|
"label": "Feature-Instanz",
|
||||||
|
"frontend_type": "text",
|
||||||
|
"frontend_readonly": True,
|
||||||
|
"frontend_required": False,
|
||||||
|
"frontend_fk_source": "/api/features/instances",
|
||||||
|
"frontend_fk_display_field": "label",
|
||||||
|
"fk_model": "FeatureInstance",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
mimeType: str = Field(
|
mimeType: str = Field(
|
||||||
description="MIME type of the file",
|
description="MIME type of the file",
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,27 @@ class UserMandate(PowerOnModel):
|
||||||
)
|
)
|
||||||
userId: str = Field(
|
userId: str = Field(
|
||||||
description="FK → User.id (CASCADE DELETE)",
|
description="FK → User.id (CASCADE DELETE)",
|
||||||
json_schema_extra={"label": "Benutzer", "frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/users/", "frontend_fk_display_field": "username"}
|
json_schema_extra={
|
||||||
|
"label": "Benutzer",
|
||||||
|
"frontend_type": "select",
|
||||||
|
"frontend_readonly": False,
|
||||||
|
"frontend_required": True,
|
||||||
|
"frontend_fk_source": "/api/users/",
|
||||||
|
"frontend_fk_display_field": "username",
|
||||||
|
"fk_model": "User",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
mandateId: str = Field(
|
mandateId: str = Field(
|
||||||
description="FK → Mandate.id (CASCADE DELETE)",
|
description="FK → Mandate.id (CASCADE DELETE)",
|
||||||
json_schema_extra={"label": "Mandant", "frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/mandates/", "frontend_fk_display_field": "label"}
|
json_schema_extra={
|
||||||
|
"label": "Mandant",
|
||||||
|
"frontend_type": "select",
|
||||||
|
"frontend_readonly": False,
|
||||||
|
"frontend_required": True,
|
||||||
|
"frontend_fk_source": "/api/mandates/",
|
||||||
|
"frontend_fk_display_field": "label",
|
||||||
|
"fk_model": "Mandate",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
enabled: bool = Field(
|
enabled: bool = Field(
|
||||||
default=True,
|
default=True,
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ Creates a complete demo environment with two mandates, one user,
|
||||||
and all feature instances needed for the investor live demo.
|
and all feature instances needed for the investor live demo.
|
||||||
|
|
||||||
Mandates:
|
Mandates:
|
||||||
- HappyLife AG (happylife) — workspace, trustee(RMA), graphEditor, chatbot, neutralization
|
- HappyLife AG (happylife) — Dokumentenablage, Buchhaltung, Automationen, Chatbot, Datenschutz
|
||||||
- Alpina Treuhand AG (alpina) — workspace, trustee(RMA), graphEditor, neutralization
|
- Alpina Treuhand AG (alpina) — Dokumentenablage, 3x Treuhand-Kunden, Automationen, Datenschutz
|
||||||
|
|
||||||
User:
|
User:
|
||||||
- Patrick Helvetia (p.motsch@poweron.swiss) — SysAdmin, member of both mandates
|
- Patrick Helvetia (p.motsch@poweron.swiss) — SysAdmin, member of both mandates
|
||||||
|
|
@ -37,12 +37,25 @@ _USER = {
|
||||||
"username": "patrick.helvetia",
|
"username": "patrick.helvetia",
|
||||||
"email": "p.motsch@poweron.swiss",
|
"email": "p.motsch@poweron.swiss",
|
||||||
"fullName": "Patrick Helvetia",
|
"fullName": "Patrick Helvetia",
|
||||||
"password": "patrick.helvetia",
|
"password": "patrick.helvetia.demo",
|
||||||
"language": "en",
|
"language": "en",
|
||||||
}
|
}
|
||||||
|
|
||||||
_FEATURES_HAPPYLIFE = ["workspace", "trustee", "graphicalEditor", "chatbot", "neutralization"]
|
_FEATURES_HAPPYLIFE = [
|
||||||
_FEATURES_ALPINA = ["workspace", "trustee", "graphicalEditor", "neutralization"]
|
{"code": "workspace", "label": "Dokumentenablage"},
|
||||||
|
{"code": "trustee", "label": "Buchhaltung"},
|
||||||
|
{"code": "graphicalEditor", "label": "Automationen"},
|
||||||
|
{"code": "chatbot", "label": "Chatbot"},
|
||||||
|
{"code": "neutralization", "label": "Datenschutz"},
|
||||||
|
]
|
||||||
|
_FEATURES_ALPINA = [
|
||||||
|
{"code": "workspace", "label": "Dokumentenablage"},
|
||||||
|
{"code": "trustee", "label": "BUHA Müller Immobilien GmbH"},
|
||||||
|
{"code": "trustee", "label": "BUHA Schneider Gastro AG"},
|
||||||
|
{"code": "trustee", "label": "BUHA Weber Consulting"},
|
||||||
|
{"code": "graphicalEditor", "label": "Automationen"},
|
||||||
|
{"code": "neutralization", "label": "Datenschutz"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class InvestorDemo2026(_BaseDemoConfig):
|
class InvestorDemo2026(_BaseDemoConfig):
|
||||||
|
|
@ -64,14 +77,17 @@ class InvestorDemo2026(_BaseDemoConfig):
|
||||||
mandateIdAlpina = self._ensureMandate(db, _MANDATE_ALPINA, summary)
|
mandateIdAlpina = self._ensureMandate(db, _MANDATE_ALPINA, summary)
|
||||||
|
|
||||||
userId = self._ensureUser(db, summary)
|
userId = self._ensureUser(db, summary)
|
||||||
|
self._ensureRootMandateSysAdminRole(db, userId, summary)
|
||||||
|
|
||||||
if mandateIdHappy:
|
if mandateIdHappy:
|
||||||
self._ensureMembership(db, userId, mandateIdHappy, _MANDATE_HAPPYLIFE["label"], summary)
|
self._ensureMembership(db, userId, mandateIdHappy, _MANDATE_HAPPYLIFE["label"], summary)
|
||||||
self._ensureFeatures(db, mandateIdHappy, _MANDATE_HAPPYLIFE["label"], _FEATURES_HAPPYLIFE, summary)
|
self._ensureFeatures(db, mandateIdHappy, _MANDATE_HAPPYLIFE["label"], _FEATURES_HAPPYLIFE, summary)
|
||||||
|
self._ensureFeatureAccess(db, userId, mandateIdHappy, _MANDATE_HAPPYLIFE["label"], summary)
|
||||||
|
|
||||||
if mandateIdAlpina:
|
if mandateIdAlpina:
|
||||||
self._ensureMembership(db, userId, mandateIdAlpina, _MANDATE_ALPINA["label"], summary)
|
self._ensureMembership(db, userId, mandateIdAlpina, _MANDATE_ALPINA["label"], summary)
|
||||||
self._ensureFeatures(db, mandateIdAlpina, _MANDATE_ALPINA["label"], _FEATURES_ALPINA, summary)
|
self._ensureFeatures(db, mandateIdAlpina, _MANDATE_ALPINA["label"], _FEATURES_ALPINA, summary)
|
||||||
|
self._ensureFeatureAccess(db, userId, mandateIdAlpina, _MANDATE_ALPINA["label"], summary)
|
||||||
|
|
||||||
self._ensureTrusteeRmaConfig(db, mandateIdHappy, _MANDATE_HAPPYLIFE["label"], summary)
|
self._ensureTrusteeRmaConfig(db, mandateIdHappy, _MANDATE_HAPPYLIFE["label"], summary)
|
||||||
self._ensureTrusteeRmaConfig(db, mandateIdAlpina, _MANDATE_ALPINA["label"], summary)
|
self._ensureTrusteeRmaConfig(db, mandateIdAlpina, _MANDATE_ALPINA["label"], summary)
|
||||||
|
|
@ -102,6 +118,7 @@ class InvestorDemo2026(_BaseDemoConfig):
|
||||||
existing = db.getRecordset(Mandate, recordFilter={"name": mandateDef["name"]})
|
existing = db.getRecordset(Mandate, recordFilter={"name": mandateDef["name"]})
|
||||||
for m in existing:
|
for m in existing:
|
||||||
mid = m.get("id")
|
mid = m.get("id")
|
||||||
|
self._removeMandateData(db, mid, mandateDef["label"], summary)
|
||||||
db.recordDelete(Mandate, mid)
|
db.recordDelete(Mandate, mid)
|
||||||
summary["removed"].append(f"Mandate {mandateDef['label']} ({mid})")
|
summary["removed"].append(f"Mandate {mandateDef['label']} ({mid})")
|
||||||
logger.info(f"Removed mandate {mandateDef['label']} ({mid})")
|
logger.info(f"Removed mandate {mandateDef['label']} ({mid})")
|
||||||
|
|
@ -178,6 +195,48 @@ class InvestorDemo2026(_BaseDemoConfig):
|
||||||
summary["created"].append(f"User {_USER['fullName']}")
|
summary["created"].append(f"User {_USER['fullName']}")
|
||||||
return uid
|
return uid
|
||||||
|
|
||||||
|
def _ensureRootMandateSysAdminRole(self, db, userId: str, summary: Dict):
|
||||||
|
"""Ensure the demo user is member of the root mandate with the sysadmin role.
|
||||||
|
Without this, hasSysAdminRole returns False and admin menus are hidden."""
|
||||||
|
from modules.datamodels.datamodelUam import Mandate
|
||||||
|
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
|
||||||
|
from modules.datamodels.datamodelRbac import Role
|
||||||
|
|
||||||
|
rootMandates = db.getRecordset(Mandate, recordFilter={"name": "root", "isSystem": True})
|
||||||
|
if not rootMandates:
|
||||||
|
summary["errors"].append("Root mandate not found — cannot assign sysadmin role")
|
||||||
|
return
|
||||||
|
|
||||||
|
rootMandateId = rootMandates[0].get("id")
|
||||||
|
|
||||||
|
existing = db.getRecordset(UserMandate, recordFilter={"userId": userId, "mandateId": rootMandateId})
|
||||||
|
if existing:
|
||||||
|
userMandateId = existing[0].get("id")
|
||||||
|
else:
|
||||||
|
um = UserMandate(userId=userId, mandateId=rootMandateId, enabled=True)
|
||||||
|
created = db.recordCreate(UserMandate, um)
|
||||||
|
userMandateId = created.get("id")
|
||||||
|
summary["created"].append("Membership -> root mandate")
|
||||||
|
logger.info(f"Created root mandate membership for {_USER['username']}")
|
||||||
|
|
||||||
|
sysadminRoles = db.getRecordset(Role, recordFilter={"mandateId": rootMandateId, "roleLabel": "sysadmin"})
|
||||||
|
if not sysadminRoles:
|
||||||
|
summary["errors"].append("sysadmin role not found in root mandate")
|
||||||
|
return
|
||||||
|
|
||||||
|
sysadminRoleId = sysadminRoles[0].get("id")
|
||||||
|
existingRole = db.getRecordset(UserMandateRole, recordFilter={
|
||||||
|
"userMandateId": userMandateId,
|
||||||
|
"roleId": sysadminRoleId,
|
||||||
|
})
|
||||||
|
if not existingRole:
|
||||||
|
umr = UserMandateRole(userMandateId=userMandateId, roleId=sysadminRoleId)
|
||||||
|
db.recordCreate(UserMandateRole, umr)
|
||||||
|
summary["created"].append("SysAdmin role in root mandate")
|
||||||
|
logger.info(f"Assigned sysadmin role in root mandate for {_USER['username']}")
|
||||||
|
else:
|
||||||
|
summary["skipped"].append("SysAdmin role in root mandate exists")
|
||||||
|
|
||||||
def _ensureMembership(self, db, userId: str, mandateId: str, mandateLabel: str, summary: Dict):
|
def _ensureMembership(self, db, userId: str, mandateId: str, mandateLabel: str, summary: Dict):
|
||||||
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
|
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
|
||||||
from modules.datamodels.datamodelRbac import Role
|
from modules.datamodels.datamodelRbac import Role
|
||||||
|
|
@ -202,33 +261,76 @@ class InvestorDemo2026(_BaseDemoConfig):
|
||||||
db.recordCreate(UserMandateRole, umr)
|
db.recordCreate(UserMandateRole, umr)
|
||||||
logger.info(f"Assigned admin role in {mandateLabel}")
|
logger.info(f"Assigned admin role in {mandateLabel}")
|
||||||
|
|
||||||
def _ensureFeatures(self, db, mandateId: str, mandateLabel: str, featureCodes: List[str], summary: Dict):
|
def _ensureFeatures(self, db, mandateId: str, mandateLabel: str, featureDefs: List[Dict], summary: Dict):
|
||||||
from modules.interfaces.interfaceFeatures import getFeatureInterface
|
from modules.interfaces.interfaceFeatures import getFeatureInterface
|
||||||
|
|
||||||
fi = getFeatureInterface(db)
|
fi = getFeatureInterface(db)
|
||||||
existingInstances = fi.getFeatureInstancesForMandate(mandateId)
|
existingInstances = fi.getFeatureInstancesForMandate(mandateId)
|
||||||
existingCodes = {
|
existingLabels = {
|
||||||
(inst.featureCode if hasattr(inst, "featureCode") else inst.get("featureCode", ""))
|
(inst.label if hasattr(inst, "label") else inst.get("label", ""))
|
||||||
for inst in existingInstances
|
for inst in existingInstances
|
||||||
}
|
}
|
||||||
|
|
||||||
for code in featureCodes:
|
for featureDef in featureDefs:
|
||||||
if code in existingCodes:
|
code = featureDef["code"]
|
||||||
summary["skipped"].append(f"Feature {code} in {mandateLabel} exists")
|
instanceLabel = featureDef["label"]
|
||||||
|
if instanceLabel in existingLabels:
|
||||||
|
summary["skipped"].append(f"Feature '{instanceLabel}' in {mandateLabel} exists")
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
fi.createFeatureInstance(
|
fi.createFeatureInstance(
|
||||||
featureCode=code,
|
featureCode=code,
|
||||||
mandateId=mandateId,
|
mandateId=mandateId,
|
||||||
label=f"{code} ({mandateLabel})",
|
label=instanceLabel,
|
||||||
enabled=True,
|
enabled=True,
|
||||||
copyTemplateRoles=True,
|
copyTemplateRoles=True,
|
||||||
)
|
)
|
||||||
summary["created"].append(f"Feature {code} in {mandateLabel}")
|
summary["created"].append(f"Feature '{instanceLabel}' in {mandateLabel}")
|
||||||
logger.info(f"Created feature instance {code} in {mandateLabel}")
|
logger.info(f"Created feature instance '{instanceLabel}' ({code}) in {mandateLabel}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
summary["errors"].append(f"Feature {code} in {mandateLabel}: {e}")
|
summary["errors"].append(f"Feature '{instanceLabel}' in {mandateLabel}: {e}")
|
||||||
logger.error(f"Failed to create feature {code} in {mandateLabel}: {e}")
|
logger.error(f"Failed to create feature '{instanceLabel}' ({code}) in {mandateLabel}: {e}")
|
||||||
|
|
||||||
|
def _ensureFeatureAccess(self, db, userId: str, mandateId: str, mandateLabel: str, summary: Dict):
|
||||||
|
"""Grant the demo user admin access to every feature instance in the mandate."""
|
||||||
|
from modules.datamodels.datamodelFeatures import FeatureInstance
|
||||||
|
from modules.datamodels.datamodelMembership import FeatureAccess, FeatureAccessRole
|
||||||
|
from modules.datamodels.datamodelRbac import Role
|
||||||
|
|
||||||
|
instances = db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId}) or []
|
||||||
|
|
||||||
|
for inst in instances:
|
||||||
|
instId = inst.get("id")
|
||||||
|
featureCode = inst.get("featureCode", "")
|
||||||
|
if not instId:
|
||||||
|
continue
|
||||||
|
|
||||||
|
existing = db.getRecordset(FeatureAccess, recordFilter={"userId": userId, "featureInstanceId": instId})
|
||||||
|
if existing:
|
||||||
|
featureAccessId = existing[0].get("id")
|
||||||
|
summary["skipped"].append(f"FeatureAccess {featureCode} in {mandateLabel} exists")
|
||||||
|
else:
|
||||||
|
fa = FeatureAccess(userId=userId, featureInstanceId=instId, enabled=True)
|
||||||
|
created = db.recordCreate(FeatureAccess, fa)
|
||||||
|
featureAccessId = created.get("id")
|
||||||
|
summary["created"].append(f"FeatureAccess {featureCode} in {mandateLabel}")
|
||||||
|
logger.info(f"Created feature access for {featureCode} in {mandateLabel}")
|
||||||
|
|
||||||
|
adminRoleLabel = f"{featureCode}-admin"
|
||||||
|
adminRoles = db.getRecordset(Role, recordFilter={
|
||||||
|
"featureInstanceId": instId,
|
||||||
|
"roleLabel": adminRoleLabel,
|
||||||
|
})
|
||||||
|
if adminRoles:
|
||||||
|
adminRoleId = adminRoles[0].get("id")
|
||||||
|
existingRole = db.getRecordset(FeatureAccessRole, recordFilter={
|
||||||
|
"featureAccessId": featureAccessId,
|
||||||
|
"roleId": adminRoleId,
|
||||||
|
})
|
||||||
|
if not existingRole:
|
||||||
|
far = FeatureAccessRole(featureAccessId=featureAccessId, roleId=adminRoleId)
|
||||||
|
db.recordCreate(FeatureAccessRole, far)
|
||||||
|
logger.info(f"Assigned {adminRoleLabel} role in {mandateLabel}")
|
||||||
|
|
||||||
def _ensureTrusteeRmaConfig(self, db, mandateId: Optional[str], mandateLabel: str, summary: Dict):
|
def _ensureTrusteeRmaConfig(self, db, mandateId: Optional[str], mandateLabel: str, summary: Dict):
|
||||||
if not mandateId:
|
if not mandateId:
|
||||||
|
|
@ -336,6 +438,157 @@ class InvestorDemo2026(_BaseDemoConfig):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
summary["errors"].append(f"Billing for {mandateLabel}: {e}")
|
summary["errors"].append(f"Billing for {mandateLabel}: {e}")
|
||||||
|
|
||||||
|
def _removeMandateData(self, db, mandateId: str, mandateLabel: str, summary: Dict):
|
||||||
|
"""Remove all data scoped to a mandate before deleting the mandate itself."""
|
||||||
|
from modules.datamodels.datamodelFeatures import FeatureInstance
|
||||||
|
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole, FeatureAccess, FeatureAccessRole
|
||||||
|
from modules.datamodels.datamodelRbac import Role, AccessRule
|
||||||
|
from modules.datamodels.datamodelChat import ChatWorkflow, ChatMessage, ChatLog
|
||||||
|
from modules.datamodels.datamodelBilling import BillingSettings
|
||||||
|
|
||||||
|
instances = db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId}) or []
|
||||||
|
|
||||||
|
for inst in instances:
|
||||||
|
instId = inst.get("id")
|
||||||
|
featureCode = inst.get("featureCode", "")
|
||||||
|
if not instId:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if featureCode == "graphicalEditor":
|
||||||
|
self._removeGraphicalEditorData(instId, mandateId, mandateLabel, summary)
|
||||||
|
|
||||||
|
if featureCode == "trustee":
|
||||||
|
self._removeTrusteeData(db, instId, mandateLabel, summary)
|
||||||
|
|
||||||
|
if featureCode == "neutralization":
|
||||||
|
self._removeNeutralizationData(db, instId, mandateLabel, summary)
|
||||||
|
|
||||||
|
chatWorkflows = db.getRecordset(ChatWorkflow, recordFilter={"featureInstanceId": instId}) or []
|
||||||
|
for wf in chatWorkflows:
|
||||||
|
wfId = wf.get("id")
|
||||||
|
if not wfId:
|
||||||
|
continue
|
||||||
|
for msg in db.getRecordset(ChatMessage, recordFilter={"workflowId": wfId}) or []:
|
||||||
|
db.recordDelete(ChatMessage, msg.get("id"))
|
||||||
|
for log in db.getRecordset(ChatLog, recordFilter={"workflowId": wfId}) or []:
|
||||||
|
db.recordDelete(ChatLog, log.get("id"))
|
||||||
|
db.recordDelete(ChatWorkflow, wfId)
|
||||||
|
if chatWorkflows:
|
||||||
|
summary["removed"].append(f"{len(chatWorkflows)} ChatWorkflows in {mandateLabel}")
|
||||||
|
|
||||||
|
accesses = db.getRecordset(FeatureAccess, recordFilter={"featureInstanceId": instId}) or []
|
||||||
|
for access in accesses:
|
||||||
|
for role in db.getRecordset(FeatureAccessRole, recordFilter={"featureAccessId": access.get("id")}) or []:
|
||||||
|
db.recordDelete(FeatureAccessRole, role.get("id"))
|
||||||
|
db.recordDelete(FeatureAccess, access.get("id"))
|
||||||
|
|
||||||
|
db.recordDelete(FeatureInstance, instId)
|
||||||
|
summary["removed"].append(f"FeatureInstance {featureCode} in {mandateLabel}")
|
||||||
|
logger.info(f"Removed feature instance {featureCode} ({instId}) in {mandateLabel}")
|
||||||
|
|
||||||
|
memberships = db.getRecordset(UserMandate, recordFilter={"mandateId": mandateId}) or []
|
||||||
|
for um in memberships:
|
||||||
|
for umr in db.getRecordset(UserMandateRole, recordFilter={"userMandateId": um.get("id")}) or []:
|
||||||
|
db.recordDelete(UserMandateRole, umr.get("id"))
|
||||||
|
db.recordDelete(UserMandate, um.get("id"))
|
||||||
|
if memberships:
|
||||||
|
summary["removed"].append(f"{len(memberships)} memberships in {mandateLabel}")
|
||||||
|
|
||||||
|
roles = db.getRecordset(Role, recordFilter={"mandateId": mandateId}) or []
|
||||||
|
for role in roles:
|
||||||
|
for rule in db.getRecordset(AccessRule, recordFilter={"roleId": role.get("id")}) or []:
|
||||||
|
db.recordDelete(AccessRule, rule.get("id"))
|
||||||
|
db.recordDelete(Role, role.get("id"))
|
||||||
|
if roles:
|
||||||
|
summary["removed"].append(f"{len(roles)} roles in {mandateLabel}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from modules.interfaces.interfaceDbBilling import _getRootInterface
|
||||||
|
billingDb = _getRootInterface().db
|
||||||
|
billingSettings = billingDb.getRecordset(BillingSettings, recordFilter={"mandateId": mandateId}) or []
|
||||||
|
for bs in billingSettings:
|
||||||
|
billingDb.recordDelete(BillingSettings, bs.get("id"))
|
||||||
|
if billingSettings:
|
||||||
|
summary["removed"].append(f"BillingSettings in {mandateLabel}")
|
||||||
|
except Exception as e:
|
||||||
|
summary["errors"].append(f"Billing cleanup for {mandateLabel}: {e}")
|
||||||
|
|
||||||
|
def _removeGraphicalEditorData(self, featureInstanceId: str, mandateId: str, mandateLabel: str, summary: Dict):
|
||||||
|
"""Remove all AutoWorkflow data (workflows, runs, versions, logs, tasks) from the Greenfield DB."""
|
||||||
|
try:
|
||||||
|
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import (
|
||||||
|
AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask,
|
||||||
|
)
|
||||||
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||||
|
from modules.shared.configuration import APP_CONFIG
|
||||||
|
|
||||||
|
geDb = DatabaseConnector(
|
||||||
|
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
|
||||||
|
dbDatabase="poweron_graphicaleditor",
|
||||||
|
dbUser=APP_CONFIG.get("DB_USER"),
|
||||||
|
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"),
|
||||||
|
dbPort=int(APP_CONFIG.get("DB_PORT", 5432)),
|
||||||
|
userId=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
workflows = geDb.getRecordset(AutoWorkflow, recordFilter={
|
||||||
|
"mandateId": mandateId,
|
||||||
|
"featureInstanceId": featureInstanceId,
|
||||||
|
}) or []
|
||||||
|
|
||||||
|
for wf in workflows:
|
||||||
|
wfId = wf.get("id")
|
||||||
|
if not wfId:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for version in geDb.getRecordset(AutoVersion, recordFilter={"workflowId": wfId}) or []:
|
||||||
|
geDb.recordDelete(AutoVersion, version.get("id"))
|
||||||
|
|
||||||
|
runs = geDb.getRecordset(AutoRun, recordFilter={"workflowId": wfId}) or []
|
||||||
|
for run in runs:
|
||||||
|
runId = run.get("id")
|
||||||
|
for stepLog in geDb.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []:
|
||||||
|
geDb.recordDelete(AutoStepLog, stepLog.get("id"))
|
||||||
|
geDb.recordDelete(AutoRun, runId)
|
||||||
|
|
||||||
|
for task in geDb.getRecordset(AutoTask, recordFilter={"workflowId": wfId}) or []:
|
||||||
|
geDb.recordDelete(AutoTask, task.get("id"))
|
||||||
|
|
||||||
|
geDb.recordDelete(AutoWorkflow, wfId)
|
||||||
|
|
||||||
|
if workflows:
|
||||||
|
summary["removed"].append(f"{len(workflows)} AutoWorkflows in {mandateLabel}")
|
||||||
|
logger.info(f"Removed {len(workflows)} graphical editor workflows for {mandateLabel}")
|
||||||
|
except Exception as e:
|
||||||
|
summary["errors"].append(f"GraphicalEditor cleanup for {mandateLabel}: {e}")
|
||||||
|
logger.error(f"Failed to clean up graphical editor data for {mandateLabel}: {e}")
|
||||||
|
|
||||||
|
def _removeTrusteeData(self, db, featureInstanceId: str, mandateLabel: str, summary: Dict):
|
||||||
|
"""Remove TrusteeAccountingConfig for a feature instance."""
|
||||||
|
try:
|
||||||
|
from modules.features.trustee.datamodelFeatureTrustee import TrusteeAccountingConfig
|
||||||
|
|
||||||
|
configs = db.getRecordset(TrusteeAccountingConfig, recordFilter={"featureInstanceId": featureInstanceId}) or []
|
||||||
|
for cfg in configs:
|
||||||
|
db.recordDelete(TrusteeAccountingConfig, cfg.get("id"))
|
||||||
|
if configs:
|
||||||
|
summary["removed"].append(f"TrusteeAccountingConfig in {mandateLabel}")
|
||||||
|
except Exception as e:
|
||||||
|
summary["errors"].append(f"Trustee cleanup for {mandateLabel}: {e}")
|
||||||
|
|
||||||
|
def _removeNeutralizationData(self, db, featureInstanceId: str, mandateLabel: str, summary: Dict):
|
||||||
|
"""Remove DataNeutraliserConfig for a feature instance."""
|
||||||
|
try:
|
||||||
|
from modules.features.neutralization.datamodelFeatureNeutralizer import DataNeutraliserConfig
|
||||||
|
|
||||||
|
configs = db.getRecordset(DataNeutraliserConfig, recordFilter={"featureInstanceId": featureInstanceId}) or []
|
||||||
|
for cfg in configs:
|
||||||
|
db.recordDelete(DataNeutraliserConfig, cfg.get("id"))
|
||||||
|
if configs:
|
||||||
|
summary["removed"].append(f"DataNeutraliserConfig in {mandateLabel}")
|
||||||
|
except Exception as e:
|
||||||
|
summary["errors"].append(f"Neutralization cleanup for {mandateLabel}: {e}")
|
||||||
|
|
||||||
def _removeLanguageSet(self, db, code: str, summary: Dict):
|
def _removeLanguageSet(self, db, code: str, summary: Dict):
|
||||||
"""Remove a language set if it was created during demo (e.g. 'es' from UC4)."""
|
"""Remove a language set if it was created during demo (e.g. 'es' from UC4)."""
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -63,11 +63,27 @@ class AutoWorkflow(PowerOnModel):
|
||||||
)
|
)
|
||||||
mandateId: str = Field(
|
mandateId: str = Field(
|
||||||
description="Mandate ID",
|
description="Mandate ID",
|
||||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Mandanten-ID"},
|
json_schema_extra={
|
||||||
|
"frontend_type": "text",
|
||||||
|
"frontend_readonly": True,
|
||||||
|
"frontend_required": False,
|
||||||
|
"label": "Mandanten-ID",
|
||||||
|
"frontend_fk_source": "/api/mandates/",
|
||||||
|
"frontend_fk_display_field": "label",
|
||||||
|
"fk_model": "Mandate",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
featureInstanceId: str = Field(
|
featureInstanceId: str = Field(
|
||||||
description="Feature instance ID",
|
description="Feature instance ID",
|
||||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Feature-Instanz-ID"},
|
json_schema_extra={
|
||||||
|
"frontend_type": "text",
|
||||||
|
"frontend_readonly": True,
|
||||||
|
"frontend_required": False,
|
||||||
|
"label": "Feature-Instanz-ID",
|
||||||
|
"frontend_fk_source": "/api/features/instances",
|
||||||
|
"frontend_fk_display_field": "label",
|
||||||
|
"fk_model": "FeatureInstance",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
label: str = Field(
|
label: str = Field(
|
||||||
description="User-friendly workflow name",
|
description="User-friendly workflow name",
|
||||||
|
|
@ -206,7 +222,15 @@ class AutoRun(PowerOnModel):
|
||||||
mandateId: Optional[str] = Field(
|
mandateId: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Mandate ID for cross-feature querying",
|
description="Mandate ID for cross-feature querying",
|
||||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Mandanten-ID"},
|
json_schema_extra={
|
||||||
|
"frontend_type": "text",
|
||||||
|
"frontend_readonly": True,
|
||||||
|
"frontend_required": False,
|
||||||
|
"label": "Mandanten-ID",
|
||||||
|
"frontend_fk_source": "/api/mandates/",
|
||||||
|
"frontend_fk_display_field": "label",
|
||||||
|
"fk_model": "Mandate",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
ownerId: Optional[str] = Field(
|
ownerId: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,16 @@ AI_NODES = [
|
||||||
{"name": "outputFormat", "type": "string", "required": False, "frontendType": "select",
|
{"name": "outputFormat", "type": "string", "required": False, "frontendType": "select",
|
||||||
"frontendOptions": {"options": ["text", "json", "emailDraft"]},
|
"frontendOptions": {"options": ["text", "json", "emailDraft"]},
|
||||||
"description": t("Ausgabeformat"), "default": "text"},
|
"description": t("Ausgabeformat"), "default": "text"},
|
||||||
|
{"name": "documentList", "type": "string", "required": False, "frontendType": "hidden",
|
||||||
|
"description": t("Dokumentenliste (via Wire oder DataRef)"), "default": ""},
|
||||||
|
{"name": "context", "type": "string", "required": False, "frontendType": "hidden",
|
||||||
|
"description": t("Kontext-Daten (via Wire oder DataRef)"), "default": ""},
|
||||||
|
{"name": "simpleMode", "type": "boolean", "required": False, "frontendType": "checkbox",
|
||||||
|
"description": t("Einfacher Modus"), "default": True},
|
||||||
],
|
],
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["DocumentList", "AiResult", "TextResult", "Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "AiResult"}},
|
"outputPorts": {0: {"schema": "AiResult"}},
|
||||||
"meta": {"icon": "mdi-robot", "color": "#9C27B0"},
|
"meta": {"icon": "mdi-robot", "color": "#9C27B0"},
|
||||||
"_method": "ai",
|
"_method": "ai",
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ FILE_NODES = [
|
||||||
{"name": "language", "type": "string", "required": False, "frontendType": "select",
|
{"name": "language", "type": "string", "required": False, "frontendType": "select",
|
||||||
"frontendOptions": {"options": ["de", "en", "fr"]},
|
"frontendOptions": {"options": ["de", "en", "fr"]},
|
||||||
"description": t("Sprache"), "default": "de"},
|
"description": t("Sprache"), "default": "de"},
|
||||||
|
{"name": "context", "type": "string", "required": False, "frontendType": "hidden",
|
||||||
|
"description": t("Inhalt (via Wire oder DataRef)"), "default": ""},
|
||||||
],
|
],
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ from fastapi import APIRouter, Depends, Path, Query, Body, Request, HTTPExceptio
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
from modules.auth import limiter, getRequestContext, RequestContext
|
from modules.auth import limiter, getRequestContext, RequestContext
|
||||||
from modules.datamodels.datamodelPagination import PaginationParams, PaginationMetadata, normalize_pagination_dict
|
from modules.datamodels.datamodelPagination import PaginationParams, PaginationMetadata, normalize_pagination_dict
|
||||||
from modules.routes.routeDataUsers import _applyFiltersAndSort
|
from modules.routes.routeHelpers import _applyFiltersAndSort
|
||||||
|
|
||||||
from modules.features.graphicalEditor.mainGraphicalEditor import getGraphicalEditorServices
|
from modules.features.graphicalEditor.mainGraphicalEditor import getGraphicalEditorServices
|
||||||
from modules.features.graphicalEditor.nodeRegistry import getNodeTypesForApi
|
from modules.features.graphicalEditor.nodeRegistry import getNodeTypesForApi
|
||||||
|
|
|
||||||
|
|
@ -579,12 +579,14 @@ class NeutralizationService:
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Extract -> neutralize -> adapt -> generate for PDF/DOCX/XLSX/PPTX."""
|
"""Extract -> neutralize -> adapt -> generate for PDF/DOCX/XLSX/PPTX."""
|
||||||
from modules.serviceCenter.services.serviceExtraction.mainServiceExtraction import ExtractionService
|
from modules.serviceCenter.services.serviceExtraction.mainServiceExtraction import ExtractionService
|
||||||
|
from modules.serviceCenter.services.serviceExtraction.subRegistry import ExtractorRegistry, ChunkerRegistry
|
||||||
from modules.serviceCenter.services.serviceExtraction.subPipeline import runExtraction
|
from modules.serviceCenter.services.serviceExtraction.subPipeline import runExtraction
|
||||||
from modules.datamodels.datamodelExtraction import ExtractionOptions, MergeStrategy
|
from modules.datamodels.datamodelExtraction import ExtractionOptions, MergeStrategy
|
||||||
|
|
||||||
# Ensure registries exist
|
|
||||||
if ExtractionService._sharedExtractorRegistry is None:
|
if ExtractionService._sharedExtractorRegistry is None:
|
||||||
ExtractionService(self.services)
|
ExtractionService._sharedExtractorRegistry = ExtractorRegistry()
|
||||||
|
if ExtractionService._sharedChunkerRegistry is None:
|
||||||
|
ExtractionService._sharedChunkerRegistry = ChunkerRegistry()
|
||||||
registry = ExtractionService._sharedExtractorRegistry
|
registry = ExtractionService._sharedExtractorRegistry
|
||||||
chunker = ExtractionService._sharedChunkerRegistry
|
chunker = ExtractionService._sharedChunkerRegistry
|
||||||
opts = ExtractionOptions(prompt="neutralize", mergeStrategy=MergeStrategy(preserveChunks=True))
|
opts = ExtractionOptions(prompt="neutralize", mergeStrategy=MergeStrategy(preserveChunks=True))
|
||||||
|
|
|
||||||
|
|
@ -216,6 +216,8 @@ def get_projects(
|
||||||
request: Request,
|
request: Request,
|
||||||
instanceId: str = Path(..., description="Feature Instance ID"),
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"),
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"),
|
||||||
|
mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"),
|
||||||
|
column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"),
|
||||||
context: RequestContext = Depends(getRequestContext)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> PaginatedResponse[Projekt]:
|
) -> PaginatedResponse[Projekt]:
|
||||||
"""Get all projects for a feature instance with optional pagination."""
|
"""Get all projects for a feature instance with optional pagination."""
|
||||||
|
|
@ -224,6 +226,17 @@ def get_projects(
|
||||||
context.user, mandateId=mandateId, featureInstanceId=instanceId
|
context.user, mandateId=mandateId, featureInstanceId=instanceId
|
||||||
)
|
)
|
||||||
recordFilter = {"featureInstanceId": instanceId}
|
recordFilter = {"featureInstanceId": instanceId}
|
||||||
|
|
||||||
|
if mode in ("filterValues", "ids"):
|
||||||
|
from modules.routes.routeHelpers import handleFilterValuesInMemory, handleIdsInMemory
|
||||||
|
items = interface.getProjekte(recordFilter=recordFilter)
|
||||||
|
itemDicts = [i.model_dump() if hasattr(i, 'model_dump') else i for i in items]
|
||||||
|
if mode == "filterValues":
|
||||||
|
if not column:
|
||||||
|
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
||||||
|
return handleFilterValuesInMemory(itemDicts, column, pagination)
|
||||||
|
return handleIdsInMemory(itemDicts, pagination)
|
||||||
|
|
||||||
items = interface.getProjekte(recordFilter=recordFilter)
|
items = interface.getProjekte(recordFilter=recordFilter)
|
||||||
paginationParams = _parsePagination(pagination)
|
paginationParams = _parsePagination(pagination)
|
||||||
if paginationParams:
|
if paginationParams:
|
||||||
|
|
@ -254,31 +267,6 @@ def get_projects(
|
||||||
return PaginatedResponse(items=items, pagination=None)
|
return PaginatedResponse(items=items, pagination=None)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{instanceId}/projects/filter-values")
|
|
||||||
@limiter.limit("60/minute")
|
|
||||||
def get_project_filter_values(
|
|
||||||
request: Request,
|
|
||||||
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
||||||
column: str = Query(..., description="Column key"),
|
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded current filters"),
|
|
||||||
context: RequestContext = Depends(getRequestContext)
|
|
||||||
) -> list:
|
|
||||||
"""Return distinct filter values for a column in real estate projects."""
|
|
||||||
mandateId = _validateInstanceAccess(instanceId, context)
|
|
||||||
try:
|
|
||||||
from modules.routes.routeDataUsers import _handleFilterValuesRequest
|
|
||||||
interface = getRealEstateInterface(
|
|
||||||
context.user, mandateId=mandateId, featureInstanceId=instanceId
|
|
||||||
)
|
|
||||||
recordFilter = {"featureInstanceId": instanceId}
|
|
||||||
items = interface.getProjekte(recordFilter=recordFilter)
|
|
||||||
itemDicts = [i.model_dump() if hasattr(i, 'model_dump') else i for i in items]
|
|
||||||
return _handleFilterValuesRequest(itemDicts, column, pagination)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting filter values for projects: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{instanceId}/projects/{projectId}", response_model=Projekt)
|
@router.get("/{instanceId}/projects/{projectId}", response_model=Projekt)
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
def get_project_by_id(
|
def get_project_by_id(
|
||||||
|
|
@ -373,6 +361,8 @@ def get_parcels(
|
||||||
request: Request,
|
request: Request,
|
||||||
instanceId: str = Path(..., description="Feature Instance ID"),
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"),
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"),
|
||||||
|
mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"),
|
||||||
|
column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"),
|
||||||
context: RequestContext = Depends(getRequestContext)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> PaginatedResponse[Parzelle]:
|
) -> PaginatedResponse[Parzelle]:
|
||||||
"""Get all parcels for a feature instance with optional pagination."""
|
"""Get all parcels for a feature instance with optional pagination."""
|
||||||
|
|
@ -381,6 +371,17 @@ def get_parcels(
|
||||||
context.user, mandateId=mandateId, featureInstanceId=instanceId
|
context.user, mandateId=mandateId, featureInstanceId=instanceId
|
||||||
)
|
)
|
||||||
recordFilter = {"featureInstanceId": instanceId}
|
recordFilter = {"featureInstanceId": instanceId}
|
||||||
|
|
||||||
|
if mode in ("filterValues", "ids"):
|
||||||
|
from modules.routes.routeHelpers import handleFilterValuesInMemory, handleIdsInMemory
|
||||||
|
items = interface.getParzellen(recordFilter=recordFilter)
|
||||||
|
itemDicts = [i.model_dump() if hasattr(i, 'model_dump') else i for i in items]
|
||||||
|
if mode == "filterValues":
|
||||||
|
if not column:
|
||||||
|
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
||||||
|
return handleFilterValuesInMemory(itemDicts, column, pagination)
|
||||||
|
return handleIdsInMemory(itemDicts, pagination)
|
||||||
|
|
||||||
items = interface.getParzellen(recordFilter=recordFilter)
|
items = interface.getParzellen(recordFilter=recordFilter)
|
||||||
paginationParams = _parsePagination(pagination)
|
paginationParams = _parsePagination(pagination)
|
||||||
if paginationParams:
|
if paginationParams:
|
||||||
|
|
@ -411,31 +412,6 @@ def get_parcels(
|
||||||
return PaginatedResponse(items=items, pagination=None)
|
return PaginatedResponse(items=items, pagination=None)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{instanceId}/parcels/filter-values")
|
|
||||||
@limiter.limit("60/minute")
|
|
||||||
def get_parcel_filter_values(
|
|
||||||
request: Request,
|
|
||||||
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
||||||
column: str = Query(..., description="Column key"),
|
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded current filters"),
|
|
||||||
context: RequestContext = Depends(getRequestContext)
|
|
||||||
) -> list:
|
|
||||||
"""Return distinct filter values for a column in real estate parcels."""
|
|
||||||
mandateId = _validateInstanceAccess(instanceId, context)
|
|
||||||
try:
|
|
||||||
from modules.routes.routeDataUsers import _handleFilterValuesRequest
|
|
||||||
interface = getRealEstateInterface(
|
|
||||||
context.user, mandateId=mandateId, featureInstanceId=instanceId
|
|
||||||
)
|
|
||||||
recordFilter = {"featureInstanceId": instanceId}
|
|
||||||
items = interface.getParzellen(recordFilter=recordFilter)
|
|
||||||
itemDicts = [i.model_dump() if hasattr(i, 'model_dump') else i for i in items]
|
|
||||||
return _handleFilterValuesRequest(itemDicts, column, pagination)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting filter values for parcels: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{instanceId}/parcels/{parcelId}", response_model=Parzelle)
|
@router.get("/{instanceId}/parcels/{parcelId}", response_model=Parzelle)
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
def get_parcel_by_id(
|
def get_parcel_by_id(
|
||||||
|
|
|
||||||
|
|
@ -327,14 +327,18 @@ QUICK_ACTIONS = [
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _buildAnalysisWorkflowGraph(prompt: str) -> Dict[str, Any]:
|
def _buildAnalysisWorkflowGraph(prompt: str) -> Dict[str, Any]:
|
||||||
"""Build a standard analysis graph: trigger → refreshAccountingData → ai.prompt."""
|
"""Build a standard analysis graph: trigger -> refreshAccountingData -> ai.prompt."""
|
||||||
return {
|
return {
|
||||||
"nodes": [
|
"nodes": [
|
||||||
{"id": "trigger", "type": "trigger.manual", "label": "Start", "_method": "", "_action": "", "parameters": {}, "position": {"x": 0, "y": 0}},
|
{"id": "trigger", "type": "trigger.manual", "label": "Start", "_method": "", "_action": "", "parameters": {}, "position": {"x": 0, "y": 0}},
|
||||||
{"id": "refresh", "type": "trustee.refreshAccountingData", "label": "Daten laden", "_method": "trustee", "_action": "refreshAccountingData",
|
{"id": "refresh", "type": "trustee.refreshAccountingData", "label": "Daten laden", "_method": "trustee", "_action": "refreshAccountingData",
|
||||||
"parameters": {"featureInstanceId": "{{featureInstanceId}}", "forceRefresh": False}, "position": {"x": 250, "y": 0}},
|
"parameters": {"featureInstanceId": "{{featureInstanceId}}", "forceRefresh": False}, "position": {"x": 250, "y": 0}},
|
||||||
{"id": "analyse", "type": "ai.prompt", "label": "Analyse", "_method": "ai", "_action": "process",
|
{"id": "analyse", "type": "ai.prompt", "label": "Analyse", "_method": "ai", "_action": "process",
|
||||||
"parameters": {"prompt": prompt, "simpleMode": False}, "position": {"x": 500, "y": 0}},
|
"parameters": {
|
||||||
|
"aiPrompt": prompt,
|
||||||
|
"context": {"type": "ref", "nodeId": "refresh", "path": ["data", "accountingData"]},
|
||||||
|
"simpleMode": False,
|
||||||
|
}, "position": {"x": 500, "y": 0}},
|
||||||
],
|
],
|
||||||
"connections": [
|
"connections": [
|
||||||
{"source": "trigger", "sourcePort": 0, "target": "refresh", "targetPort": 0},
|
{"source": "trigger", "sourcePort": 0, "target": "refresh", "targetPort": 0},
|
||||||
|
|
@ -387,15 +391,33 @@ TEMPLATE_WORKFLOWS = [
|
||||||
"label": "Budget-Vergleich",
|
"label": "Budget-Vergleich",
|
||||||
"description": "Soll/Ist-Vergleich der Buchhaltung mit Budget-Excel",
|
"description": "Soll/Ist-Vergleich der Buchhaltung mit Budget-Excel",
|
||||||
"tags": ["feature:trustee", "template:trustee-budget-comparison"],
|
"tags": ["feature:trustee", "template:trustee-budget-comparison"],
|
||||||
"graph": _buildAnalysisWorkflowGraph(
|
"graph": {
|
||||||
"Ich möchte einen Budget-Soll/Ist-Vergleich durchführen. Bitte:\n"
|
"nodes": [
|
||||||
"1. Frage mich nach der Budget-Datei (Excel) oder suche im Workspace nach einer Datei mit 'Budget' im Namen\n"
|
{"id": "trigger", "type": "trigger.manual", "label": "Start", "_method": "", "_action": "", "parameters": {}, "position": {"x": 0, "y": 0}},
|
||||||
"2. Lade die aktuellen Buchhaltungsdaten (refreshTrusteeData falls nötig)\n"
|
{"id": "refresh", "type": "trustee.refreshAccountingData", "label": "Daten laden", "_method": "trustee", "_action": "refreshAccountingData",
|
||||||
"3. Vergleiche die Soll-Werte aus dem Budget mit den Ist-Werten aus der Buchhaltung pro Konto\n"
|
"parameters": {"featureInstanceId": "{{featureInstanceId}}", "forceRefresh": False}, "position": {"x": 250, "y": 0}},
|
||||||
"4. Berechne die Abweichung (absolut und prozentual)\n"
|
{"id": "analyse", "type": "ai.prompt", "label": "Budget-Analyse", "_method": "ai", "_action": "process",
|
||||||
"5. Erstelle ein Abweichungs-Chart (Balkendiagramm: Soll vs. Ist pro Konto)\n"
|
"parameters": {
|
||||||
"6. Markiere kritische Abweichungen (>10%) und gib eine kurze Einschätzung"
|
"aiPrompt": (
|
||||||
|
"Fuehre einen Budget-Soll/Ist-Vergleich durch.\n"
|
||||||
|
"Die Budget-Datei (Excel) wurde als Dokument uebergeben. "
|
||||||
|
"Die aktuellen Buchhaltungsdaten sind im Kontext verfuegbar.\n"
|
||||||
|
"1. Lies die Soll-Werte aus dem uebergebenen Budget-Dokument\n"
|
||||||
|
"2. Vergleiche sie mit den Ist-Werten aus der Buchhaltung pro Konto\n"
|
||||||
|
"3. Berechne die Abweichung (absolut und prozentual)\n"
|
||||||
|
"4. Erstelle ein Abweichungs-Chart (Balkendiagramm: Soll vs. Ist pro Konto)\n"
|
||||||
|
"5. Markiere kritische Abweichungen (>10%) und gib eine kurze Einschaetzung"
|
||||||
),
|
),
|
||||||
|
"documentList": {"type": "ref", "nodeId": "trigger", "path": ["payload", "documentList"]},
|
||||||
|
"context": {"type": "ref", "nodeId": "refresh", "path": ["data", "accountingData"]},
|
||||||
|
"simpleMode": False,
|
||||||
|
}, "position": {"x": 500, "y": 0}},
|
||||||
|
],
|
||||||
|
"connections": [
|
||||||
|
{"source": "trigger", "sourcePort": 0, "target": "refresh", "targetPort": 0},
|
||||||
|
{"source": "refresh", "sourcePort": 0, "target": "analyse", "targetPort": 0},
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "trustee-kpi-dashboard",
|
"id": "trustee-kpi-dashboard",
|
||||||
|
|
|
||||||
|
|
@ -897,11 +897,16 @@ def get_documents(
|
||||||
request: Request,
|
request: Request,
|
||||||
instanceId: str = Path(..., description="Feature Instance ID"),
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
||||||
pagination: Optional[str] = Query(None),
|
pagination: Optional[str] = Query(None),
|
||||||
|
mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"),
|
||||||
|
column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"),
|
||||||
context: RequestContext = Depends(getRequestContext)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> PaginatedResponse[TrusteeDocument]:
|
) -> PaginatedResponse[TrusteeDocument]:
|
||||||
"""Get all documents (metadata only) with optional pagination."""
|
"""Get all documents (metadata only) with optional pagination."""
|
||||||
mandateId = _validateInstanceAccess(instanceId, context)
|
mandateId = _validateInstanceAccess(instanceId, context)
|
||||||
|
|
||||||
|
if mode in ("filterValues", "ids"):
|
||||||
|
return _handleDocumentMode(instanceId, mandateId, mode, column, pagination, context)
|
||||||
|
|
||||||
paginationParams = _parsePagination(pagination)
|
paginationParams = _parsePagination(pagination)
|
||||||
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
||||||
result = interface.getAllDocuments(paginationParams)
|
result = interface.getAllDocuments(paginationParams)
|
||||||
|
|
@ -921,36 +926,18 @@ def get_documents(
|
||||||
return PaginatedResponse(items=result if isinstance(result, list) else result.items, pagination=None)
|
return PaginatedResponse(items=result if isinstance(result, list) else result.items, pagination=None)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{instanceId}/documents/filter-values")
|
def _handleDocumentMode(instanceId, mandateId, mode, column, pagination, context):
|
||||||
@limiter.limit("60/minute")
|
"""Handle mode=filterValues and mode=ids for trustee documents."""
|
||||||
def get_document_filter_values(
|
from modules.routes.routeHelpers import handleFilterValuesInMemory, handleIdsInMemory
|
||||||
request: Request,
|
if mode == "filterValues":
|
||||||
instanceId: str = Path(..., description="Feature Instance ID"),
|
if not column:
|
||||||
column: str = Query(..., description="Column key"),
|
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded current filters"),
|
|
||||||
context: RequestContext = Depends(getRequestContext)
|
|
||||||
) -> list:
|
|
||||||
"""Return distinct filter values for a column in trustee documents."""
|
|
||||||
mandateId = _validateInstanceAccess(instanceId, context)
|
|
||||||
try:
|
try:
|
||||||
from modules.interfaces.interfaceRbac import getDistinctColumnValuesWithRBAC
|
from modules.interfaces.interfaceRbac import getDistinctColumnValuesWithRBAC
|
||||||
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
||||||
|
from modules.routes.routeHelpers import parseCrossFilterPagination
|
||||||
crossFilterPagination = None
|
crossFilterPagination = parseCrossFilterPagination(column, pagination)
|
||||||
if pagination:
|
from fastapi.responses import JSONResponse
|
||||||
try:
|
|
||||||
paginationDict = json.loads(pagination)
|
|
||||||
if paginationDict:
|
|
||||||
paginationDict = normalize_pagination_dict(paginationDict)
|
|
||||||
filters = paginationDict.get("filters", {})
|
|
||||||
filters.pop(column, None)
|
|
||||||
paginationDict["filters"] = filters
|
|
||||||
paginationDict.pop("sort", None)
|
|
||||||
crossFilterPagination = PaginationParams(**paginationDict)
|
|
||||||
except (json.JSONDecodeError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
values = getDistinctColumnValuesWithRBAC(
|
values = getDistinctColumnValuesWithRBAC(
|
||||||
connector=interface.db,
|
connector=interface.db,
|
||||||
modelClass=TrusteeDocument,
|
modelClass=TrusteeDocument,
|
||||||
|
|
@ -962,15 +949,17 @@ def get_document_filter_values(
|
||||||
featureInstanceId=interface.featureInstanceId,
|
featureInstanceId=interface.featureInstanceId,
|
||||||
featureCode=interface.FEATURE_CODE
|
featureCode=interface.FEATURE_CODE
|
||||||
)
|
)
|
||||||
return sorted(values, key=lambda v: str(v).lower())
|
return JSONResponse(content=sorted(values, key=lambda v: str(v).lower()))
|
||||||
except Exception:
|
except Exception:
|
||||||
from modules.routes.routeDataUsers import _handleFilterValuesRequest
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
||||||
result = interface.getAllDocuments(None)
|
result = interface.getAllDocuments(None)
|
||||||
items = [r.model_dump() if hasattr(r, 'model_dump') else r for r in (result.items if hasattr(result, 'items') else result)]
|
items = [r.model_dump() if hasattr(r, 'model_dump') else r for r in (result.items if hasattr(result, 'items') else result)]
|
||||||
return _handleFilterValuesRequest(items, column, pagination)
|
return handleFilterValuesInMemory(items, column, pagination)
|
||||||
except Exception as e:
|
if mode == "ids":
|
||||||
logger.error(f"Error getting filter values for trustee documents: {str(e)}")
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
result = interface.getAllDocuments(None)
|
||||||
|
items = [r.model_dump() if hasattr(r, 'model_dump') else r for r in (result.items if hasattr(result, 'items') else result)]
|
||||||
|
return handleIdsInMemory(items, pagination)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{instanceId}/documents/{documentId}", response_model=TrusteeDocument)
|
@router.get("/{instanceId}/documents/{documentId}", response_model=TrusteeDocument)
|
||||||
|
|
@ -1153,11 +1142,16 @@ def get_positions(
|
||||||
request: Request,
|
request: Request,
|
||||||
instanceId: str = Path(..., description="Feature Instance ID"),
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
||||||
pagination: Optional[str] = Query(None),
|
pagination: Optional[str] = Query(None),
|
||||||
|
mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"),
|
||||||
|
column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"),
|
||||||
context: RequestContext = Depends(getRequestContext)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> PaginatedResponse[TrusteePosition]:
|
) -> PaginatedResponse[TrusteePosition]:
|
||||||
"""Get all positions with optional pagination."""
|
"""Get all positions with optional pagination."""
|
||||||
mandateId = _validateInstanceAccess(instanceId, context)
|
mandateId = _validateInstanceAccess(instanceId, context)
|
||||||
|
|
||||||
|
if mode in ("filterValues", "ids"):
|
||||||
|
return _handlePositionMode(instanceId, mandateId, mode, column, pagination, context)
|
||||||
|
|
||||||
paginationParams = _parsePagination(pagination)
|
paginationParams = _parsePagination(pagination)
|
||||||
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
||||||
result = interface.getAllPositions(paginationParams)
|
result = interface.getAllPositions(paginationParams)
|
||||||
|
|
@ -1177,36 +1171,18 @@ def get_positions(
|
||||||
return PaginatedResponse(items=result if isinstance(result, list) else result.items, pagination=None)
|
return PaginatedResponse(items=result if isinstance(result, list) else result.items, pagination=None)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{instanceId}/positions/filter-values")
|
def _handlePositionMode(instanceId, mandateId, mode, column, pagination, context):
|
||||||
@limiter.limit("60/minute")
|
"""Handle mode=filterValues and mode=ids for trustee positions."""
|
||||||
def get_position_filter_values(
|
from modules.routes.routeHelpers import handleFilterValuesInMemory, handleIdsInMemory
|
||||||
request: Request,
|
if mode == "filterValues":
|
||||||
instanceId: str = Path(..., description="Feature Instance ID"),
|
if not column:
|
||||||
column: str = Query(..., description="Column key"),
|
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded current filters"),
|
|
||||||
context: RequestContext = Depends(getRequestContext)
|
|
||||||
) -> list:
|
|
||||||
"""Return distinct filter values for a column in trustee positions."""
|
|
||||||
mandateId = _validateInstanceAccess(instanceId, context)
|
|
||||||
try:
|
try:
|
||||||
from modules.interfaces.interfaceRbac import getDistinctColumnValuesWithRBAC
|
from modules.interfaces.interfaceRbac import getDistinctColumnValuesWithRBAC
|
||||||
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
||||||
|
from modules.routes.routeHelpers import parseCrossFilterPagination
|
||||||
crossFilterPagination = None
|
crossFilterPagination = parseCrossFilterPagination(column, pagination)
|
||||||
if pagination:
|
from fastapi.responses import JSONResponse
|
||||||
try:
|
|
||||||
paginationDict = json.loads(pagination)
|
|
||||||
if paginationDict:
|
|
||||||
paginationDict = normalize_pagination_dict(paginationDict)
|
|
||||||
filters = paginationDict.get("filters", {})
|
|
||||||
filters.pop(column, None)
|
|
||||||
paginationDict["filters"] = filters
|
|
||||||
paginationDict.pop("sort", None)
|
|
||||||
crossFilterPagination = PaginationParams(**paginationDict)
|
|
||||||
except (json.JSONDecodeError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
values = getDistinctColumnValuesWithRBAC(
|
values = getDistinctColumnValuesWithRBAC(
|
||||||
connector=interface.db,
|
connector=interface.db,
|
||||||
modelClass=TrusteePosition,
|
modelClass=TrusteePosition,
|
||||||
|
|
@ -1218,15 +1194,17 @@ def get_position_filter_values(
|
||||||
featureInstanceId=interface.featureInstanceId,
|
featureInstanceId=interface.featureInstanceId,
|
||||||
featureCode=interface.FEATURE_CODE
|
featureCode=interface.FEATURE_CODE
|
||||||
)
|
)
|
||||||
return sorted(values, key=lambda v: str(v).lower())
|
return JSONResponse(content=sorted(values, key=lambda v: str(v).lower()))
|
||||||
except Exception:
|
except Exception:
|
||||||
from modules.routes.routeDataUsers import _handleFilterValuesRequest
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
||||||
result = interface.getAllPositions(None)
|
result = interface.getAllPositions(None)
|
||||||
items = [r.model_dump() if hasattr(r, 'model_dump') else r for r in (result.items if hasattr(result, 'items') else result)]
|
items = [r.model_dump() if hasattr(r, 'model_dump') else r for r in (result.items if hasattr(result, 'items') else result)]
|
||||||
return _handleFilterValuesRequest(items, column, pagination)
|
return handleFilterValuesInMemory(items, column, pagination)
|
||||||
except Exception as e:
|
if mode == "ids":
|
||||||
logger.error(f"Error getting filter values for trustee positions: {str(e)}")
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
result = interface.getAllPositions(None)
|
||||||
|
items = [r.model_dump() if hasattr(r, 'model_dump') else r for r in (result.items if hasattr(result, 'items') else result)]
|
||||||
|
return handleIdsInMemory(items, pagination)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{instanceId}/positions/{positionId}", response_model=TrusteePosition)
|
@router.get("/{instanceId}/positions/{positionId}", response_model=TrusteePosition)
|
||||||
|
|
|
||||||
|
|
@ -97,39 +97,9 @@ def initBootstrap(db: DatabaseConnector) -> None:
|
||||||
# Apply multi-tenant database optimizations (indexes, triggers, FKs)
|
# Apply multi-tenant database optimizations (indexes, triggers, FKs)
|
||||||
_applyDatabaseOptimizations(db)
|
_applyDatabaseOptimizations(db)
|
||||||
|
|
||||||
# Run root-user migration (one-time, sets completion flag)
|
# Initialize root mandate feature instances
|
||||||
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:
|
if mandateId:
|
||||||
initRootMandateFeatures(db, 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}")
|
|
||||||
|
|
||||||
# Run voice & documents migration (one-time, sets completion flag)
|
|
||||||
try:
|
|
||||||
from modules.migration.migrateVoiceAndDocuments import migrateVoiceAndDocuments
|
|
||||||
migrateVoiceAndDocuments(db)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Voice & documents migration failed: {e}")
|
|
||||||
|
|
||||||
# Backfill FileContentIndex scope fields from FileItem (one-time)
|
|
||||||
try:
|
|
||||||
from modules.migration.migrateRagScopeFields import runMigration as migrateRagScope
|
|
||||||
migrateRagScope(appDb=db)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"RAG scope fields migration failed: {e}")
|
|
||||||
|
|
||||||
# After migration: root mandate is purely technical — no feature instances
|
|
||||||
if not migrationDone and 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
|
||||||
_cleanupRemovedFeatureInstances(db)
|
_cleanupRemovedFeatureInstances(db)
|
||||||
|
|
@ -307,10 +277,11 @@ def initRootMandateFeatures(db: DatabaseConnector, mandateId: str) -> None:
|
||||||
for featureName, module in mainModules.items():
|
for featureName, module in mainModules.items():
|
||||||
if hasattr(module, "getFeatureDefinition"):
|
if hasattr(module, "getFeatureDefinition"):
|
||||||
try:
|
try:
|
||||||
|
from modules.shared.i18nRegistry import resolveText
|
||||||
featureDef = module.getFeatureDefinition()
|
featureDef = module.getFeatureDefinition()
|
||||||
if featureDef.get("autoCreateInstance", False):
|
if featureDef.get("autoCreateInstance", False):
|
||||||
featureCode = featureDef.get("code", featureName)
|
featureCode = featureDef.get("code", featureName)
|
||||||
featureLabel = featureDef.get("label", {}).get("en", featureName)
|
featureLabel = resolveText(featureDef.get("label", featureName))
|
||||||
featuresToCreate.append({"code": featureCode, "label": featureLabel})
|
featuresToCreate.append({"code": featureCode, "label": featureLabel})
|
||||||
logger.debug(f"Feature '{featureCode}' marked for auto-creation in root mandate")
|
logger.debug(f"Feature '{featureCode}' marked for auto-creation in root mandate")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -1728,6 +1728,9 @@ class AppObjects:
|
||||||
|
|
||||||
instances = self.db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId})
|
instances = self.db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId})
|
||||||
|
|
||||||
|
# 0-pre. Delete AutoWorkflow data in Greenfield DB (poweron_graphicaleditor)
|
||||||
|
self._cascadeDeleteGraphicalEditorData(mandateId, instances)
|
||||||
|
|
||||||
# 0. Delete instance-scoped data for each FeatureInstance
|
# 0. Delete instance-scoped data for each FeatureInstance
|
||||||
for inst in instances:
|
for inst in instances:
|
||||||
instId = inst.get("id")
|
instId = inst.get("id")
|
||||||
|
|
@ -1869,6 +1872,67 @@ class AppObjects:
|
||||||
logger.error(f"Error deleting mandate: {str(e)}")
|
logger.error(f"Error deleting mandate: {str(e)}")
|
||||||
raise ValueError(f"Failed to delete mandate: {str(e)}")
|
raise ValueError(f"Failed to delete mandate: {str(e)}")
|
||||||
|
|
||||||
|
def _cascadeDeleteGraphicalEditorData(self, mandateId: str, instances) -> None:
|
||||||
|
"""Delete AutoWorkflow + related data in the Greenfield DB for all graphicalEditor instances."""
|
||||||
|
try:
|
||||||
|
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import (
|
||||||
|
AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask,
|
||||||
|
)
|
||||||
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||||
|
|
||||||
|
geDb = DatabaseConnector(
|
||||||
|
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
|
||||||
|
dbDatabase="poweron_graphicaleditor",
|
||||||
|
dbUser=APP_CONFIG.get("DB_USER"),
|
||||||
|
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"),
|
||||||
|
dbPort=int(APP_CONFIG.get("DB_PORT", 5432)),
|
||||||
|
userId=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not geDb._ensureTableExists(AutoWorkflow):
|
||||||
|
return
|
||||||
|
|
||||||
|
geInstances = [
|
||||||
|
inst for inst in instances
|
||||||
|
if (inst.get("featureCode") if isinstance(inst, dict) else getattr(inst, "featureCode", "")) == "graphicalEditor"
|
||||||
|
]
|
||||||
|
|
||||||
|
totalDeleted = 0
|
||||||
|
for inst in geInstances:
|
||||||
|
instId = inst.get("id") if isinstance(inst, dict) else getattr(inst, "id", None)
|
||||||
|
if not instId:
|
||||||
|
continue
|
||||||
|
|
||||||
|
workflows = geDb.getRecordset(AutoWorkflow, recordFilter={
|
||||||
|
"mandateId": mandateId,
|
||||||
|
"featureInstanceId": instId,
|
||||||
|
}) or []
|
||||||
|
|
||||||
|
for wf in workflows:
|
||||||
|
wfId = wf.get("id")
|
||||||
|
if not wfId:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for v in geDb.getRecordset(AutoVersion, recordFilter={"workflowId": wfId}) or []:
|
||||||
|
geDb.recordDelete(AutoVersion, v.get("id"))
|
||||||
|
|
||||||
|
for run in geDb.getRecordset(AutoRun, recordFilter={"workflowId": wfId}) or []:
|
||||||
|
runId = run.get("id")
|
||||||
|
for sl in geDb.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []:
|
||||||
|
geDb.recordDelete(AutoStepLog, sl.get("id"))
|
||||||
|
geDb.recordDelete(AutoRun, runId)
|
||||||
|
|
||||||
|
for task in geDb.getRecordset(AutoTask, recordFilter={"workflowId": wfId}) or []:
|
||||||
|
geDb.recordDelete(AutoTask, task.get("id"))
|
||||||
|
|
||||||
|
geDb.recordDelete(AutoWorkflow, wfId)
|
||||||
|
totalDeleted += 1
|
||||||
|
|
||||||
|
if totalDeleted:
|
||||||
|
logger.info(f"Cascade: deleted {totalDeleted} AutoWorkflow(s) in Greenfield DB for mandate {mandateId}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to cascade-delete graphical editor data for mandate {mandateId}: {e}")
|
||||||
|
|
||||||
def restoreMandate(self, mandateId: str) -> bool:
|
def restoreMandate(self, mandateId: str) -> bool:
|
||||||
"""Restore a soft-deleted mandate (undo soft-delete within the 30-day retention window)."""
|
"""Restore a soft-deleted mandate (undo soft-delete within the 30-day retention window)."""
|
||||||
mandate = self.getMandate(mandateId)
|
mandate = self.getMandate(mandateId)
|
||||||
|
|
|
||||||
|
|
@ -585,7 +585,7 @@ def aggregateMandateRagTotalBytes(mandateId: str) -> int:
|
||||||
|
|
||||||
# DEPRECATED: file-ID-correlation fallback from poweron_management.
|
# DEPRECATED: file-ID-correlation fallback from poweron_management.
|
||||||
# Only needed for pre-migration data where mandateId/featureInstanceId on the
|
# Only needed for pre-migration data where mandateId/featureInstanceId on the
|
||||||
# FileContentIndex are empty. Remove once migrateRagScopeFields has been run.
|
# FileContentIndex are empty. Safe to remove once all environments are migrated.
|
||||||
_fallbackCount = 0
|
_fallbackCount = 0
|
||||||
try:
|
try:
|
||||||
from modules.datamodels.datamodelFiles import FileItem
|
from modules.datamodels.datamodelFiles import FileItem
|
||||||
|
|
|
||||||
|
|
@ -232,7 +232,11 @@ class FeatureInterface:
|
||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
try:
|
try:
|
||||||
featureModule = importlib.import_module(f"modules.features.{featureCode}.main{featureCode.capitalize()}")
|
from modules.system.registry import loadFeatureMainModules
|
||||||
|
mainModules = loadFeatureMainModules()
|
||||||
|
featureModule = mainModules.get(featureCode)
|
||||||
|
if not featureModule:
|
||||||
|
return 0
|
||||||
getTemplateWorkflows = getattr(featureModule, "getTemplateWorkflows", None)
|
getTemplateWorkflows = getattr(featureModule, "getTemplateWorkflows", None)
|
||||||
if not getTemplateWorkflows:
|
if not getTemplateWorkflows:
|
||||||
return 0
|
return 0
|
||||||
|
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
|
||||||
# All rights reserved.
|
|
||||||
"""
|
|
||||||
Migration: Backfill FileContentIndex scope fields from FileItem (Single Source of Truth).
|
|
||||||
|
|
||||||
Fixes legacy rows in poweron_knowledge where scope/mandateId/featureInstanceId
|
|
||||||
are empty or default ("personal") despite the corresponding FileItem having correct values.
|
|
||||||
|
|
||||||
Idempotent — safe to run multiple times. Uses a DB flag to skip if already completed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from modules.shared.configuration import APP_CONFIG
|
|
||||||
from modules.connectors.connectorDbPostgre import _get_cached_connector
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_MIGRATION_FLAG_KEY = "migration_rag_scope_fields_completed"
|
|
||||||
|
|
||||||
|
|
||||||
def _isMigrationCompleted(appDb) -> bool:
|
|
||||||
try:
|
|
||||||
from modules.datamodels.datamodelUam import Mandate
|
|
||||||
records = appDb.getRecordset(Mandate, recordFilter={"name": _MIGRATION_FLAG_KEY})
|
|
||||||
return len(records) > 0
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _setMigrationCompleted(appDb) -> None:
|
|
||||||
try:
|
|
||||||
from modules.datamodels.datamodelUam import Mandate
|
|
||||||
flag = Mandate(name=_MIGRATION_FLAG_KEY, description="RAG scope fields migration completed")
|
|
||||||
appDb.recordCreate(Mandate, flag)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Could not set migration flag: %s", e)
|
|
||||||
|
|
||||||
|
|
||||||
def runMigration(appDb=None) -> dict:
|
|
||||||
"""Backfill FileContentIndex rows from FileItem metadata.
|
|
||||||
|
|
||||||
Returns dict with counts: {total, updated, skipped, orphaned}.
|
|
||||||
"""
|
|
||||||
from modules.datamodels.datamodelKnowledge import FileContentIndex
|
|
||||||
from modules.datamodels.datamodelFiles import FileItem
|
|
||||||
from modules.interfaces.interfaceDbKnowledge import getInterface as getKnowledgeInterface
|
|
||||||
from modules.interfaces.interfaceDbManagement import ComponentObjects
|
|
||||||
|
|
||||||
if appDb is None:
|
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
|
||||||
appDb = getRootInterface().db
|
|
||||||
|
|
||||||
if _isMigrationCompleted(appDb):
|
|
||||||
logger.info("migrateRagScopeFields: already completed, skipping")
|
|
||||||
return {"total": 0, "updated": 0, "skipped": 0, "orphaned": 0}
|
|
||||||
|
|
||||||
knowDb = getKnowledgeInterface(None).db
|
|
||||||
mgmtDb = ComponentObjects().db
|
|
||||||
|
|
||||||
allIndexes = knowDb.getRecordset(FileContentIndex, recordFilter={})
|
|
||||||
total = len(allIndexes)
|
|
||||||
updated = 0
|
|
||||||
skipped = 0
|
|
||||||
orphaned = 0
|
|
||||||
|
|
||||||
logger.info("migrateRagScopeFields: processing %d FileContentIndex rows", total)
|
|
||||||
|
|
||||||
for idx in allIndexes:
|
|
||||||
idxId = idx.get("id") if isinstance(idx, dict) else getattr(idx, "id", None)
|
|
||||||
if not idxId:
|
|
||||||
skipped += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
fileItem = mgmtDb._loadRecord(FileItem, str(idxId))
|
|
||||||
if not fileItem:
|
|
||||||
orphaned += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
_get = (lambda k, d="": fileItem.get(k, d)) if isinstance(fileItem, dict) else (lambda k, d="": getattr(fileItem, k, d))
|
|
||||||
|
|
||||||
fiScope = _get("scope") or "personal"
|
|
||||||
fiMandateId = str(_get("mandateId") or "")
|
|
||||||
fiFeatureInstanceId = str(_get("featureInstanceId") or "")
|
|
||||||
|
|
||||||
idxGet = (lambda k, d="": idx.get(k, d)) if isinstance(idx, dict) else (lambda k, d="": getattr(idx, k, d))
|
|
||||||
currentScope = idxGet("scope") or "personal"
|
|
||||||
currentMandateId = str(idxGet("mandateId") or "")
|
|
||||||
currentFeatureInstanceId = str(idxGet("featureInstanceId") or "")
|
|
||||||
|
|
||||||
updates = {}
|
|
||||||
if fiScope != currentScope:
|
|
||||||
updates["scope"] = fiScope
|
|
||||||
if fiMandateId and fiMandateId != currentMandateId:
|
|
||||||
updates["mandateId"] = fiMandateId
|
|
||||||
if fiFeatureInstanceId and fiFeatureInstanceId != currentFeatureInstanceId:
|
|
||||||
updates["featureInstanceId"] = fiFeatureInstanceId
|
|
||||||
|
|
||||||
if updates:
|
|
||||||
try:
|
|
||||||
knowDb.recordModify(FileContentIndex, str(idxId), updates)
|
|
||||||
updated += 1
|
|
||||||
logger.debug("migrateRagScopeFields: updated %s -> %s", idxId, updates)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("migrateRagScopeFields: failed to update %s: %s", idxId, e)
|
|
||||||
skipped += 1
|
|
||||||
else:
|
|
||||||
skipped += 1
|
|
||||||
|
|
||||||
_setMigrationCompleted(appDb)
|
|
||||||
logger.info(
|
|
||||||
"migrateRagScopeFields complete: total=%d, updated=%d, skipped=%d, orphaned=%d",
|
|
||||||
total, updated, skipped, orphaned,
|
|
||||||
)
|
|
||||||
return {"total": total, "updated": updated, "skipped": skipped, "orphaned": orphaned}
|
|
||||||
|
|
@ -1,329 +0,0 @@
|
||||||
# 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, List, Dict, Any
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_MIGRATION_FLAG_KEY = "migration_root_users_completed"
|
|
||||||
|
|
||||||
_DATA_TABLES = [
|
|
||||||
"ChatWorkflow",
|
|
||||||
"FileItem",
|
|
||||||
"DataSource",
|
|
||||||
"DataNeutralizerAttributes",
|
|
||||||
"FileContentIndex",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _isMigrationCompleted(db) -> bool:
|
|
||||||
"""Check if migration has already been executed."""
|
|
||||||
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 _findOrCreateTargetInstance(db, featureInterface, featureCode: str, targetMandateId: str, rootInstance: dict) -> dict:
|
|
||||||
"""Find existing or create new FeatureInstance in target mandate. Idempotent."""
|
|
||||||
from modules.datamodels.datamodelFeatures import FeatureInstance
|
|
||||||
|
|
||||||
existing = db.getRecordset(FeatureInstance, recordFilter={
|
|
||||||
"featureCode": featureCode,
|
|
||||||
"mandateId": targetMandateId,
|
|
||||||
})
|
|
||||||
if existing:
|
|
||||||
logger.debug(f"Target instance already exists for {featureCode} in mandate {targetMandateId}")
|
|
||||||
return existing[0]
|
|
||||||
|
|
||||||
label = rootInstance.get("label") or featureCode
|
|
||||||
instance = featureInterface.createFeatureInstance(
|
|
||||||
featureCode=featureCode,
|
|
||||||
mandateId=targetMandateId,
|
|
||||||
label=label,
|
|
||||||
enabled=True,
|
|
||||||
copyTemplateRoles=True,
|
|
||||||
)
|
|
||||||
if isinstance(instance, dict):
|
|
||||||
return instance
|
|
||||||
return instance.model_dump() if hasattr(instance, "model_dump") else {"id": instance.id}
|
|
||||||
|
|
||||||
|
|
||||||
def _migrateDataRecords(db, oldInstanceId: str, newInstanceId: str, userId: str) -> int:
|
|
||||||
"""Bulk-update featureInstanceId on all data tables for records owned by userId."""
|
|
||||||
totalMigrated = 0
|
|
||||||
db._ensure_connection()
|
|
||||||
for tableName in _DATA_TABLES:
|
|
||||||
try:
|
|
||||||
with db.connection.cursor() as cursor:
|
|
||||||
cursor.execute(
|
|
||||||
f'UPDATE "{tableName}" '
|
|
||||||
f'SET "featureInstanceId" = %s '
|
|
||||||
f'WHERE "featureInstanceId" = %s AND "sysCreatedBy" = %s',
|
|
||||||
(newInstanceId, oldInstanceId, userId),
|
|
||||||
)
|
|
||||||
count = cursor.rowcount
|
|
||||||
db.connection.commit()
|
|
||||||
if count > 0:
|
|
||||||
logger.info(f" Migrated {count} rows in {tableName}: {oldInstanceId} -> {newInstanceId}")
|
|
||||||
totalMigrated += count
|
|
||||||
except Exception as e:
|
|
||||||
try:
|
|
||||||
db.connection.rollback()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
logger.debug(f" Table {tableName} skipped (may not exist or no matching column): {e}")
|
|
||||||
return totalMigrated
|
|
||||||
|
|
||||||
|
|
||||||
def _grantFeatureAccess(db, userId: str, featureInstanceId: str) -> dict:
|
|
||||||
"""Create FeatureAccess + admin role on a feature instance. Idempotent."""
|
|
||||||
from modules.datamodels.datamodelMembership import FeatureAccess, FeatureAccessRole
|
|
||||||
from modules.datamodels.datamodelRbac import Role
|
|
||||||
|
|
||||||
existing = db.getRecordset(FeatureAccess, recordFilter={
|
|
||||||
"userId": userId,
|
|
||||||
"featureInstanceId": featureInstanceId,
|
|
||||||
})
|
|
||||||
if existing:
|
|
||||||
logger.debug(f"FeatureAccess already exists for user {userId} on instance {featureInstanceId}")
|
|
||||||
return existing[0]
|
|
||||||
|
|
||||||
fa = FeatureAccess(userId=userId, featureInstanceId=featureInstanceId, enabled=True)
|
|
||||||
createdFa = db.recordCreate(FeatureAccess, fa.model_dump())
|
|
||||||
if not createdFa:
|
|
||||||
logger.warning(f"Failed to create FeatureAccess for user {userId} on instance {featureInstanceId}")
|
|
||||||
return {}
|
|
||||||
|
|
||||||
instanceRoles = db.getRecordset(Role, recordFilter={"featureInstanceId": featureInstanceId})
|
|
||||||
adminRoleId = None
|
|
||||||
for r in instanceRoles:
|
|
||||||
roleLabel = (r.get("roleLabel") or "").lower()
|
|
||||||
if roleLabel.endswith("-admin"):
|
|
||||||
adminRoleId = r.get("id")
|
|
||||||
break
|
|
||||||
if not adminRoleId:
|
|
||||||
raise ValueError(
|
|
||||||
f"No feature-specific admin role for instance {featureInstanceId}. "
|
|
||||||
f"Cannot create FeatureAccess without role — even in migration context."
|
|
||||||
)
|
|
||||||
far = FeatureAccessRole(featureAccessId=createdFa["id"], roleId=adminRoleId)
|
|
||||||
db.recordCreate(FeatureAccessRole, far.model_dump())
|
|
||||||
|
|
||||||
return createdFa
|
|
||||||
|
|
||||||
|
|
||||||
def migrateRootUsers(db, dryRun: bool = False) -> dict:
|
|
||||||
"""
|
|
||||||
Migrate all end-user feature data from Root mandate to personal mandates.
|
|
||||||
|
|
||||||
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
|
|
||||||
from modules.interfaces.interfaceFeatures import getFeatureInterface
|
|
||||||
|
|
||||||
rootInterface = getRootInterface()
|
|
||||||
featureInterface = getFeatureInterface(db)
|
|
||||||
stats = {
|
|
||||||
"usersProcessed": 0,
|
|
||||||
"mandatesCreated": 0,
|
|
||||||
"instancesMigrated": 0,
|
|
||||||
"dataRowsMigrated": 0,
|
|
||||||
"rootInstancesDeleted": 0,
|
|
||||||
"rootMembershipsRemoved": 0,
|
|
||||||
"dryRun": dryRun,
|
|
||||||
}
|
|
||||||
|
|
||||||
# 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,
|
|
||||||
mandateName=f"Home {username}",
|
|
||||||
planKey="TRIAL_14D",
|
|
||||||
)
|
|
||||||
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:
|
|
||||||
targetInstance = _findOrCreateTargetInstance(
|
|
||||||
db, featureInterface, featureCode, targetMandateId, instRecords[0],
|
|
||||||
)
|
|
||||||
newInstanceId = targetInstance.get("id")
|
|
||||||
if not newInstanceId:
|
|
||||||
logger.error(f"Failed to obtain target instance for {featureCode} in mandate {targetMandateId}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
migratedCount = _migrateDataRecords(db, oldInstanceId, newInstanceId, userId)
|
|
||||||
|
|
||||||
_grantFeatureAccess(db, userId, newInstanceId)
|
|
||||||
|
|
||||||
try:
|
|
||||||
db.recordDelete(FeatureAccess, oldAccessId)
|
|
||||||
except Exception as delErr:
|
|
||||||
logger.warning(f"Could not remove old FeatureAccess {oldAccessId}: {delErr}")
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Migrated {featureCode} for {username}: "
|
|
||||||
f"instance {oldInstanceId} -> {newInstanceId}, {migratedCount} data rows moved"
|
|
||||||
)
|
|
||||||
stats["instancesMigrated"] += 1
|
|
||||||
stats["dataRowsMigrated"] += migratedCount
|
|
||||||
|
|
||||||
stats["usersProcessed"] += 1
|
|
||||||
|
|
||||||
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}
|
|
||||||
|
|
@ -1,316 +0,0 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
|
||||||
# All rights reserved.
|
|
||||||
"""
|
|
||||||
Migration: Voice settings consolidation and CoachingDocument scope-tagging.
|
|
||||||
Moves VoiceSettings (workspace DB) and CoachingUserProfile voice fields (commcoach DB)
|
|
||||||
into the unified UserVoicePreferences model, and tags CoachingDocument files with
|
|
||||||
featureInstance scope before deleting the legacy records.
|
|
||||||
Called once from bootstrap, sets a DB flag to prevent re-execution.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import uuid
|
|
||||||
from typing import Dict, List, Optional
|
|
||||||
|
|
||||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
|
||||||
from modules.shared.configuration import APP_CONFIG
|
|
||||||
from modules.datamodels.datamodelUam import UserVoicePreferences
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_MIGRATION_FLAG_KEY = "migration_voice_documents_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: voice & documents migration completed")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to set migration flag: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def _getRawRows(connector: DatabaseConnector, tableName: str, columns: List[str]) -> List[Dict]:
|
|
||||||
"""Read all rows from a table via raw SQL. Returns empty list if table doesn't exist."""
|
|
||||||
try:
|
|
||||||
connector._ensure_connection()
|
|
||||||
colList = ", ".join(f'"{c}"' for c in columns)
|
|
||||||
with connector.connection.cursor() as cur:
|
|
||||||
cur.execute(
|
|
||||||
"SELECT COUNT(*) FROM information_schema.tables "
|
|
||||||
"WHERE LOWER(table_name) = LOWER(%s) AND table_schema = 'public'",
|
|
||||||
(tableName,),
|
|
||||||
)
|
|
||||||
if cur.fetchone()["count"] == 0:
|
|
||||||
logger.info(f"Table '{tableName}' does not exist, skipping")
|
|
||||||
return []
|
|
||||||
cur.execute(f'SELECT {colList} FROM "{tableName}"')
|
|
||||||
return [dict(row) for row in cur.fetchall()]
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Raw query on '{tableName}' failed: {e}")
|
|
||||||
try:
|
|
||||||
connector.connection.rollback()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def _deleteRawRow(connector: DatabaseConnector, tableName: str, rowId: str) -> bool:
|
|
||||||
"""Delete a single row by id via raw SQL."""
|
|
||||||
try:
|
|
||||||
connector._ensure_connection()
|
|
||||||
with connector.connection.cursor() as cur:
|
|
||||||
cur.execute(f'DELETE FROM "{tableName}" WHERE "id" = %s', (rowId,))
|
|
||||||
connector.connection.commit()
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to delete row {rowId} from '{tableName}': {e}")
|
|
||||||
try:
|
|
||||||
connector.connection.rollback()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _createDbConnector(dbName: str) -> Optional[DatabaseConnector]:
|
|
||||||
"""Create a DatabaseConnector for a named database, returns None on failure."""
|
|
||||||
try:
|
|
||||||
dbHost = APP_CONFIG.get("DB_HOST")
|
|
||||||
dbUser = APP_CONFIG.get("DB_USER")
|
|
||||||
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
|
||||||
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
|
||||||
return DatabaseConnector(
|
|
||||||
dbHost=dbHost,
|
|
||||||
dbDatabase=dbName,
|
|
||||||
dbUser=dbUser,
|
|
||||||
dbPassword=dbPassword,
|
|
||||||
dbPort=dbPort,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Could not connect to database '{dbName}': {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Part A ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _migrateVoiceSettings(db, wsDb: DatabaseConnector, dryRun: bool, stats: Dict) -> None:
|
|
||||||
"""Migrate VoiceSettings records from poweron_workspace into UserVoicePreferences."""
|
|
||||||
rows = _getRawRows(wsDb, "VoiceSettings", [
|
|
||||||
"id", "userId", "mandateId", "ttsVoiceMap", "sttLanguage", "ttsLanguage", "ttsVoice",
|
|
||||||
])
|
|
||||||
if not rows:
|
|
||||||
logger.info("Part A: No VoiceSettings records found, skipping")
|
|
||||||
return
|
|
||||||
|
|
||||||
for row in rows:
|
|
||||||
userId = row.get("userId")
|
|
||||||
if not userId:
|
|
||||||
continue
|
|
||||||
|
|
||||||
existing = db.getRecordset(UserVoicePreferences, recordFilter={"userId": userId})
|
|
||||||
if existing:
|
|
||||||
stats["voiceSettingsSkipped"] += 1
|
|
||||||
if not dryRun:
|
|
||||||
_deleteRawRow(wsDb, "VoiceSettings", row["id"])
|
|
||||||
continue
|
|
||||||
|
|
||||||
if dryRun:
|
|
||||||
logger.info(f"[DRY RUN] Would create UserVoicePreferences for user {userId} from VoiceSettings")
|
|
||||||
stats["voiceSettingsCreated"] += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
import json
|
|
||||||
ttsVoiceMap = row.get("ttsVoiceMap")
|
|
||||||
if isinstance(ttsVoiceMap, str):
|
|
||||||
try:
|
|
||||||
ttsVoiceMap = json.loads(ttsVoiceMap)
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
ttsVoiceMap = None
|
|
||||||
|
|
||||||
prefs = UserVoicePreferences(
|
|
||||||
userId=userId,
|
|
||||||
mandateId=row.get("mandateId"),
|
|
||||||
ttsVoiceMap=ttsVoiceMap,
|
|
||||||
sttLanguage=row.get("sttLanguage", "de-DE"),
|
|
||||||
ttsLanguage=row.get("ttsLanguage", "de-DE"),
|
|
||||||
ttsVoice=row.get("ttsVoice"),
|
|
||||||
)
|
|
||||||
db.recordCreate(UserVoicePreferences, prefs)
|
|
||||||
stats["voiceSettingsCreated"] += 1
|
|
||||||
_deleteRawRow(wsDb, "VoiceSettings", row["id"])
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Part A: Failed to migrate VoiceSettings {row['id']}: {e}")
|
|
||||||
stats["errors"] += 1
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Part B ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _migrateCoachingProfileVoice(db, ccDb: DatabaseConnector, dryRun: bool, stats: Dict) -> None:
|
|
||||||
"""Migrate preferredLanguage/preferredVoice from CoachingUserProfile into UserVoicePreferences."""
|
|
||||||
rows = _getRawRows(ccDb, "CoachingUserProfile", [
|
|
||||||
"id", "userId", "mandateId", "preferredLanguage", "preferredVoice",
|
|
||||||
])
|
|
||||||
if not rows:
|
|
||||||
logger.info("Part B: No CoachingUserProfile records with voice data found, skipping")
|
|
||||||
return
|
|
||||||
|
|
||||||
for row in rows:
|
|
||||||
userId = row.get("userId")
|
|
||||||
prefLang = row.get("preferredLanguage")
|
|
||||||
prefVoice = row.get("preferredVoice")
|
|
||||||
if not userId or (not prefLang and not prefVoice):
|
|
||||||
continue
|
|
||||||
|
|
||||||
existing = db.getRecordset(UserVoicePreferences, recordFilter={"userId": userId})
|
|
||||||
if existing:
|
|
||||||
stats["coachingProfileSkipped"] += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
if dryRun:
|
|
||||||
logger.info(f"[DRY RUN] Would create UserVoicePreferences for user {userId} from CoachingUserProfile")
|
|
||||||
stats["coachingProfileCreated"] += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
prefs = UserVoicePreferences(
|
|
||||||
userId=userId,
|
|
||||||
mandateId=row.get("mandateId"),
|
|
||||||
sttLanguage=prefLang or "de-DE",
|
|
||||||
ttsLanguage=prefLang or "de-DE",
|
|
||||||
ttsVoice=prefVoice,
|
|
||||||
)
|
|
||||||
db.recordCreate(UserVoicePreferences, prefs)
|
|
||||||
stats["coachingProfileCreated"] += 1
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Part B: Failed to migrate CoachingUserProfile {row['id']}: {e}")
|
|
||||||
stats["errors"] += 1
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Part C ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _migrateCoachingDocuments(ccDb: DatabaseConnector, dryRun: bool, stats: Dict) -> None:
|
|
||||||
"""Tag FileItem/FileContentIndex with featureInstance scope for each CoachingDocument."""
|
|
||||||
from modules.datamodels.datamodelFiles import FileItem
|
|
||||||
from modules.datamodels.datamodelKnowledge import FileContentIndex
|
|
||||||
|
|
||||||
rows = _getRawRows(ccDb, "CoachingDocument", [
|
|
||||||
"id", "fileRef", "instanceId",
|
|
||||||
])
|
|
||||||
if not rows:
|
|
||||||
logger.info("Part C: No CoachingDocument records found, skipping")
|
|
||||||
return
|
|
||||||
|
|
||||||
mgmtDb = _createDbConnector("poweron_management")
|
|
||||||
knowledgeDb = _createDbConnector("poweron_knowledge")
|
|
||||||
if not mgmtDb:
|
|
||||||
logger.error("Part C: Cannot connect to poweron_management, aborting document migration")
|
|
||||||
return
|
|
||||||
|
|
||||||
for row in rows:
|
|
||||||
fileRef = row.get("fileRef")
|
|
||||||
instanceId = row.get("instanceId")
|
|
||||||
docId = row.get("id")
|
|
||||||
if not fileRef:
|
|
||||||
if not dryRun:
|
|
||||||
_deleteRawRow(ccDb, "CoachingDocument", docId)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if dryRun:
|
|
||||||
logger.info(f"[DRY RUN] Would tag FileItem {fileRef} with featureInstanceId={instanceId}")
|
|
||||||
stats["documentsTagged"] += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
fileRecords = mgmtDb.getRecordset(FileItem, recordFilter={"id": fileRef})
|
|
||||||
if fileRecords:
|
|
||||||
updateData = {"scope": "featureInstance"}
|
|
||||||
if instanceId:
|
|
||||||
updateData["featureInstanceId"] = instanceId
|
|
||||||
mgmtDb.recordModify(FileItem, fileRef, updateData)
|
|
||||||
stats["documentsTagged"] += 1
|
|
||||||
else:
|
|
||||||
logger.warning(f"Part C: FileItem {fileRef} not found in management DB")
|
|
||||||
|
|
||||||
if knowledgeDb:
|
|
||||||
fciRecords = knowledgeDb.getRecordset(FileContentIndex, recordFilter={"id": fileRef})
|
|
||||||
if fciRecords:
|
|
||||||
fciUpdate = {"scope": "featureInstance"}
|
|
||||||
if instanceId:
|
|
||||||
fciUpdate["featureInstanceId"] = instanceId
|
|
||||||
knowledgeDb.recordModify(FileContentIndex, fileRef, fciUpdate)
|
|
||||||
|
|
||||||
_deleteRawRow(ccDb, "CoachingDocument", docId)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Part C: Failed to migrate CoachingDocument {docId}: {e}")
|
|
||||||
stats["errors"] += 1
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Main entry ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def migrateVoiceAndDocuments(db, dryRun: bool = False) -> dict:
|
|
||||||
"""
|
|
||||||
Migrate VoiceSettings + CoachingUserProfile voice fields into UserVoicePreferences,
|
|
||||||
and tag CoachingDocument files with featureInstance scope.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db: Root database connector (poweron_app)
|
|
||||||
dryRun: If True, log actions without making changes
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Summary dict with migration statistics
|
|
||||||
"""
|
|
||||||
if _isMigrationCompleted(db):
|
|
||||||
logger.info("Voice & documents migration already completed, skipping")
|
|
||||||
return {"status": "already_completed"}
|
|
||||||
|
|
||||||
stats = {
|
|
||||||
"voiceSettingsCreated": 0,
|
|
||||||
"voiceSettingsSkipped": 0,
|
|
||||||
"coachingProfileCreated": 0,
|
|
||||||
"coachingProfileSkipped": 0,
|
|
||||||
"documentsTagged": 0,
|
|
||||||
"errors": 0,
|
|
||||||
"dryRun": dryRun,
|
|
||||||
}
|
|
||||||
|
|
||||||
wsDb = _createDbConnector("poweron_workspace")
|
|
||||||
ccDb = _createDbConnector("poweron_commcoach")
|
|
||||||
|
|
||||||
# Part A
|
|
||||||
if wsDb:
|
|
||||||
_migrateVoiceSettings(db, wsDb, dryRun, stats)
|
|
||||||
else:
|
|
||||||
logger.warning("Skipping Part A: poweron_workspace DB unavailable")
|
|
||||||
|
|
||||||
# Part B
|
|
||||||
if ccDb:
|
|
||||||
_migrateCoachingProfileVoice(db, ccDb, dryRun, stats)
|
|
||||||
else:
|
|
||||||
logger.warning("Skipping Part B: poweron_commcoach DB unavailable")
|
|
||||||
|
|
||||||
# Part C
|
|
||||||
if ccDb:
|
|
||||||
_migrateCoachingDocuments(ccDb, dryRun, stats)
|
|
||||||
else:
|
|
||||||
logger.warning("Skipping Part C: poweron_commcoach DB unavailable")
|
|
||||||
|
|
||||||
if not dryRun:
|
|
||||||
_setMigrationCompleted(db)
|
|
||||||
|
|
||||||
logger.info(f"Voice & documents migration completed: {stats}")
|
|
||||||
return {"status": "completed", **stats}
|
|
||||||
|
|
@ -18,7 +18,7 @@ import json
|
||||||
import math
|
import math
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from modules.datamodels.datamodelPagination import PaginationParams, PaginationMetadata, normalize_pagination_dict
|
from modules.datamodels.datamodelPagination import PaginationParams, PaginationMetadata, normalize_pagination_dict
|
||||||
from modules.routes.routeDataUsers import _applyFiltersAndSort, _extractDistinctValues
|
from modules.routes.routeHelpers import _applyFiltersAndSort, handleFilterValuesInMemory, handleIdsInMemory
|
||||||
|
|
||||||
from modules.auth import limiter, getRequestContext, RequestContext, requireSysAdminRole
|
from modules.auth import limiter, getRequestContext, RequestContext, requireSysAdminRole
|
||||||
from modules.datamodels.datamodelUam import User, UserInDB
|
from modules.datamodels.datamodelUam import User, UserInDB
|
||||||
|
|
@ -405,6 +405,8 @@ def list_feature_instances(
|
||||||
request: Request,
|
request: Request,
|
||||||
featureCode: Optional[str] = Query(None, description="Filter by feature code"),
|
featureCode: Optional[str] = Query(None, description="Filter by feature code"),
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||||
|
mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"),
|
||||||
|
column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"),
|
||||||
context: RequestContext = Depends(getRequestContext)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
|
|
@ -454,6 +456,14 @@ def list_feature_instances(
|
||||||
|
|
||||||
items = [inst.model_dump() for inst in instances]
|
items = [inst.model_dump() for inst in instances]
|
||||||
|
|
||||||
|
if mode == "filterValues":
|
||||||
|
if not column:
|
||||||
|
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
||||||
|
return handleFilterValuesInMemory(items, column, pagination)
|
||||||
|
|
||||||
|
if mode == "ids":
|
||||||
|
return handleIdsInMemory(items, pagination)
|
||||||
|
|
||||||
if paginationParams:
|
if paginationParams:
|
||||||
filtered = _applyFiltersAndSort(items, paginationParams)
|
filtered = _applyFiltersAndSort(items, paginationParams)
|
||||||
totalItems = len(filtered)
|
totalItems = len(filtered)
|
||||||
|
|
@ -484,35 +494,6 @@ def list_feature_instances(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/instances/filter-values")
|
|
||||||
@limiter.limit("60/minute")
|
|
||||||
def get_feature_instance_filter_values(
|
|
||||||
request: Request,
|
|
||||||
column: str = Query(..., description="Column key"),
|
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded current filters"),
|
|
||||||
featureCode: Optional[str] = Query(None, description="Filter by feature code"),
|
|
||||||
context: RequestContext = Depends(getRequestContext)
|
|
||||||
) -> list:
|
|
||||||
"""Return distinct filter values for a column in feature instances."""
|
|
||||||
if not context.mandateId:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=routeApiMsg("X-Mandate-Id header is required"))
|
|
||||||
try:
|
|
||||||
from modules.routes.routeDataUsers import _handleFilterValuesRequest
|
|
||||||
rootInterface = getRootInterface()
|
|
||||||
featureInterface = getFeatureInterface(rootInterface.db)
|
|
||||||
instances = featureInterface.getFeatureInstancesForMandate(
|
|
||||||
mandateId=str(context.mandateId),
|
|
||||||
featureCode=featureCode
|
|
||||||
)
|
|
||||||
items = [inst.model_dump() for inst in instances]
|
|
||||||
return _handleFilterValuesRequest(items, column, pagination)
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting filter values for feature instances: {e}")
|
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/instances/{instanceId}", response_model=Dict[str, Any])
|
@router.get("/instances/{instanceId}", response_model=Dict[str, Any])
|
||||||
@limiter.limit("60/minute")
|
@limiter.limit("60/minute")
|
||||||
def get_feature_instance(
|
def get_feature_instance(
|
||||||
|
|
@ -860,6 +841,115 @@ def sync_instance_roles(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SyncWorkflowsResult(BaseModel):
|
||||||
|
"""Response model for workflow synchronization"""
|
||||||
|
added: int
|
||||||
|
skipped: int
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/instances/{instanceId}/sync-workflows", response_model=SyncWorkflowsResult)
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
def _syncInstanceWorkflows(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str,
|
||||||
|
context: RequestContext = Depends(getRequestContext)
|
||||||
|
) -> SyncWorkflowsResult:
|
||||||
|
"""
|
||||||
|
Synchronize template workflows for a feature instance.
|
||||||
|
|
||||||
|
Copies missing template workflows to the instance. Workflows that already
|
||||||
|
exist (matched by templateSourceId) are skipped. This is useful for
|
||||||
|
instances created before template workflows were defined, or when
|
||||||
|
the initial copy failed silently.
|
||||||
|
|
||||||
|
SysAdmin only.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
requireSysAdminRole(context.user)
|
||||||
|
|
||||||
|
rootInterface = getRootInterface()
|
||||||
|
featureInterface = getFeatureInterface(rootInterface.db)
|
||||||
|
|
||||||
|
instance = featureInterface.getFeatureInstance(instanceId)
|
||||||
|
if not instance:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Feature instance '{instanceId}' not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
featureCode = instance.get("featureCode") if isinstance(instance, dict) else instance.featureCode
|
||||||
|
mandateId = instance.get("mandateId") if isinstance(instance, dict) else instance.mandateId
|
||||||
|
|
||||||
|
from modules.system.registry import loadFeatureMainModules
|
||||||
|
mainModules = loadFeatureMainModules()
|
||||||
|
featureModule = mainModules.get(featureCode)
|
||||||
|
if not featureModule:
|
||||||
|
return SyncWorkflowsResult(added=0, skipped=0, total=0)
|
||||||
|
getTemplateWorkflows = getattr(featureModule, "getTemplateWorkflows", None)
|
||||||
|
|
||||||
|
if not getTemplateWorkflows:
|
||||||
|
return SyncWorkflowsResult(added=0, skipped=0, total=0)
|
||||||
|
|
||||||
|
templateWorkflows = getTemplateWorkflows()
|
||||||
|
if not templateWorkflows:
|
||||||
|
return SyncWorkflowsResult(added=0, skipped=0, total=0)
|
||||||
|
|
||||||
|
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface
|
||||||
|
from modules.security.rootAccess import getRootUser
|
||||||
|
|
||||||
|
rootUser = getRootUser()
|
||||||
|
geInterface = getGraphicalEditorInterface(rootUser, mandateId, instanceId)
|
||||||
|
|
||||||
|
existingWorkflows = geInterface.getWorkflows() or []
|
||||||
|
existingSourceIds = set()
|
||||||
|
for w in existingWorkflows:
|
||||||
|
sourceId = w.get("templateSourceId") if isinstance(w, dict) else getattr(w, "templateSourceId", None)
|
||||||
|
if sourceId:
|
||||||
|
existingSourceIds.add(sourceId)
|
||||||
|
|
||||||
|
added = 0
|
||||||
|
skipped = 0
|
||||||
|
for template in templateWorkflows:
|
||||||
|
if template["id"] in existingSourceIds:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
import json as _json
|
||||||
|
graphJson = _json.dumps(template.get("graph", {}))
|
||||||
|
graphJson = graphJson.replace("{{featureInstanceId}}", instanceId)
|
||||||
|
graph = _json.loads(graphJson)
|
||||||
|
|
||||||
|
label = resolveText(template.get("label"))
|
||||||
|
|
||||||
|
geInterface.createWorkflow({
|
||||||
|
"label": label,
|
||||||
|
"graph": graph,
|
||||||
|
"tags": template.get("tags", [f"feature:{featureCode}"]),
|
||||||
|
"isTemplate": False,
|
||||||
|
"templateSourceId": template["id"],
|
||||||
|
"templateScope": "instance",
|
||||||
|
"active": True,
|
||||||
|
})
|
||||||
|
added += 1
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"User {context.user.id} synced workflows for instance {instanceId} "
|
||||||
|
f"({featureCode}): added={added}, skipped={skipped}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return SyncWorkflowsResult(added=added, skipped=skipped, total=len(templateWorkflows))
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error syncing workflows for instance {instanceId}: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to sync workflows: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Template Role Endpoints (SysAdmin only)
|
# Template Role Endpoints (SysAdmin only)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -883,6 +973,8 @@ def list_template_roles(
|
||||||
request: Request,
|
request: Request,
|
||||||
featureCode: Optional[str] = Query(None, description="Filter by feature code"),
|
featureCode: Optional[str] = Query(None, description="Filter by feature code"),
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"),
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"),
|
||||||
|
mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"),
|
||||||
|
column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"),
|
||||||
sysAdmin: User = Depends(requireSysAdminRole),
|
sysAdmin: User = Depends(requireSysAdminRole),
|
||||||
):
|
):
|
||||||
"""List global template roles with pagination support."""
|
"""List global template roles with pagination support."""
|
||||||
|
|
@ -898,6 +990,15 @@ def list_template_roles(
|
||||||
raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}")
|
raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}")
|
||||||
|
|
||||||
enriched = _buildTemplateRolesList(featureCode)
|
enriched = _buildTemplateRolesList(featureCode)
|
||||||
|
|
||||||
|
if mode == "filterValues":
|
||||||
|
if not column:
|
||||||
|
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
||||||
|
return handleFilterValuesInMemory(enriched, column, pagination)
|
||||||
|
|
||||||
|
if mode == "ids":
|
||||||
|
return handleIdsInMemory(enriched, pagination)
|
||||||
|
|
||||||
filtered = _applyFiltersAndSort(enriched, paginationParams)
|
filtered = _applyFiltersAndSort(enriched, paginationParams)
|
||||||
|
|
||||||
if paginationParams:
|
if paginationParams:
|
||||||
|
|
@ -927,39 +1028,6 @@ def list_template_roles(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/templates/roles/filter-values")
|
|
||||||
@limiter.limit("60/minute")
|
|
||||||
def get_template_role_filter_values(
|
|
||||||
request: Request,
|
|
||||||
column: str = Query(..., description="Column key"),
|
|
||||||
featureCode: Optional[str] = Query(None, description="Filter by feature code"),
|
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded current filters"),
|
|
||||||
sysAdmin: User = Depends(requireSysAdminRole),
|
|
||||||
):
|
|
||||||
"""Return distinct filter values for a column in template roles."""
|
|
||||||
try:
|
|
||||||
crossFilterParams: Optional[PaginationParams] = None
|
|
||||||
if pagination:
|
|
||||||
try:
|
|
||||||
paginationDict = json.loads(pagination)
|
|
||||||
if paginationDict:
|
|
||||||
paginationDict = normalize_pagination_dict(paginationDict)
|
|
||||||
filters = paginationDict.get("filters", {})
|
|
||||||
filters.pop(column, None)
|
|
||||||
paginationDict["filters"] = filters
|
|
||||||
paginationDict.pop("sort", None)
|
|
||||||
crossFilterParams = PaginationParams(**paginationDict)
|
|
||||||
except (json.JSONDecodeError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
enriched = _buildTemplateRolesList(featureCode)
|
|
||||||
crossFiltered = _applyFiltersAndSort(enriched, crossFilterParams)
|
|
||||||
return _extractDistinctValues(crossFiltered, column)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting filter values: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/templates/roles", response_model=Dict[str, Any])
|
@router.post("/templates/roles", response_model=Dict[str, Any])
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
def create_template_role(
|
def create_template_role(
|
||||||
|
|
@ -1051,6 +1119,8 @@ def list_feature_instance_users(
|
||||||
request: Request,
|
request: Request,
|
||||||
instanceId: str,
|
instanceId: str,
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||||
|
mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"),
|
||||||
|
column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"),
|
||||||
context: RequestContext = Depends(getRequestContext)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
|
|
@ -1114,6 +1184,14 @@ def list_feature_instance_users(
|
||||||
|
|
||||||
items = [r.model_dump() for r in result]
|
items = [r.model_dump() for r in result]
|
||||||
|
|
||||||
|
if mode == "filterValues":
|
||||||
|
if not column:
|
||||||
|
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
||||||
|
return handleFilterValuesInMemory(items, column, pagination)
|
||||||
|
|
||||||
|
if mode == "ids":
|
||||||
|
return handleIdsInMemory(items, pagination)
|
||||||
|
|
||||||
paginationParams = None
|
paginationParams = None
|
||||||
if pagination:
|
if pagination:
|
||||||
try:
|
try:
|
||||||
|
|
@ -1150,56 +1228,6 @@ def list_feature_instance_users(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/instances/{instanceId}/users/filter-values")
|
|
||||||
@limiter.limit("60/minute")
|
|
||||||
def get_feature_instance_users_filter_values(
|
|
||||||
request: Request,
|
|
||||||
instanceId: str,
|
|
||||||
column: str = Query(..., description="Column key"),
|
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded current filters"),
|
|
||||||
context: RequestContext = Depends(getRequestContext)
|
|
||||||
) -> list:
|
|
||||||
"""Return distinct filter values for a column in feature instance users."""
|
|
||||||
try:
|
|
||||||
from modules.routes.routeDataUsers import _handleFilterValuesRequest
|
|
||||||
rootInterface = getRootInterface()
|
|
||||||
featureInterface = getFeatureInterface(rootInterface.db)
|
|
||||||
instance = featureInterface.getFeatureInstance(instanceId)
|
|
||||||
if not instance:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Feature instance '{instanceId}' not found")
|
|
||||||
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
|
||||||
if not context.hasSysAdminRole:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Access denied to this feature instance"))
|
|
||||||
featureAccesses = rootInterface.getFeatureAccessesByInstance(instanceId)
|
|
||||||
result = []
|
|
||||||
for fa in featureAccesses:
|
|
||||||
user = rootInterface.getUser(str(fa.userId))
|
|
||||||
if not user:
|
|
||||||
continue
|
|
||||||
roleIds = rootInterface.getRoleIdsForFeatureAccess(str(fa.id))
|
|
||||||
roleLabels = []
|
|
||||||
for roleId in roleIds:
|
|
||||||
role = rootInterface.getRole(roleId)
|
|
||||||
if role:
|
|
||||||
roleLabels.append(role.roleLabel)
|
|
||||||
result.append({
|
|
||||||
"id": str(fa.id),
|
|
||||||
"userId": str(fa.userId),
|
|
||||||
"username": user.username,
|
|
||||||
"email": user.email,
|
|
||||||
"fullName": user.fullName,
|
|
||||||
"roleIds": roleIds,
|
|
||||||
"roleLabels": roleLabels,
|
|
||||||
"enabled": fa.enabled
|
|
||||||
})
|
|
||||||
return _handleFilterValuesRequest(result, column, pagination)
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting filter values for feature instance users: {e}")
|
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/instances/{instanceId}/users", response_model=Dict[str, Any])
|
@router.post("/instances/{instanceId}/users", response_model=Dict[str, Any])
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
def add_user_to_feature_instance(
|
def add_user_to_feature_instance(
|
||||||
|
|
|
||||||
|
|
@ -810,6 +810,8 @@ def list_roles(
|
||||||
includeTemplates: bool = Query(False, description="Include feature template roles"),
|
includeTemplates: bool = Query(False, description="Include feature template roles"),
|
||||||
mandateId: Optional[str] = Query(None, description="Include mandate-specific roles for this mandate"),
|
mandateId: Optional[str] = Query(None, description="Include mandate-specific roles for this mandate"),
|
||||||
scopeFilter: Optional[str] = Query(None, description="Filter by scope: 'all', 'mandate', 'global', 'system'"),
|
scopeFilter: Optional[str] = Query(None, description="Filter by scope: 'all', 'mandate', 'global', 'system'"),
|
||||||
|
mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"),
|
||||||
|
column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"),
|
||||||
reqContext: RequestContext = Depends(getRequestContext)
|
reqContext: RequestContext = Depends(getRequestContext)
|
||||||
) -> PaginatedResponse:
|
) -> PaginatedResponse:
|
||||||
"""
|
"""
|
||||||
|
|
@ -924,6 +926,16 @@ def list_roles(
|
||||||
if not isSysAdmin:
|
if not isSysAdmin:
|
||||||
result = [r for r in result if r.get("mandateId") and str(r["mandateId"]) in adminMandateIds]
|
result = [r for r in result if r.get("mandateId") and str(r["mandateId"]) in adminMandateIds]
|
||||||
|
|
||||||
|
if mode == "filterValues":
|
||||||
|
if not column:
|
||||||
|
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
||||||
|
from modules.routes.routeHelpers import handleFilterValuesInMemory
|
||||||
|
return handleFilterValuesInMemory(result, column, pagination)
|
||||||
|
|
||||||
|
if mode == "ids":
|
||||||
|
from modules.routes.routeHelpers import handleIdsInMemory
|
||||||
|
return handleIdsInMemory(result, pagination)
|
||||||
|
|
||||||
# Apply search, filtering and sorting if pagination requested
|
# Apply search, filtering and sorting if pagination requested
|
||||||
if paginationParams:
|
if paginationParams:
|
||||||
# Apply search (if search term provided in filters)
|
# Apply search (if search term provided in filters)
|
||||||
|
|
@ -987,77 +999,6 @@ def list_roles(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/roles/filter-values")
|
|
||||||
@limiter.limit("60/minute")
|
|
||||||
def get_roles_filter_values(
|
|
||||||
request: Request,
|
|
||||||
column: str = Query(..., description="Column key"),
|
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded current filters"),
|
|
||||||
includeTemplates: bool = Query(False, description="Include feature template roles"),
|
|
||||||
mandateId: Optional[str] = Query(None, description="Include mandate-specific roles for this mandate"),
|
|
||||||
scopeFilter: Optional[str] = Query(None, description="Filter by scope: 'all', 'mandate', 'global', 'system'"),
|
|
||||||
reqContext: RequestContext = Depends(getRequestContext)
|
|
||||||
) -> list:
|
|
||||||
"""Return distinct filter values for a column in roles."""
|
|
||||||
try:
|
|
||||||
from modules.routes.routeDataUsers import _handleFilterValuesRequest
|
|
||||||
isSysAdmin = reqContext.hasSysAdminRole
|
|
||||||
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
|
|
||||||
if not isSysAdmin and not adminMandateIds:
|
|
||||||
raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required"))
|
|
||||||
|
|
||||||
interface = getRootInterface()
|
|
||||||
dbRoles = interface.getAllRoles(pagination=None)
|
|
||||||
roleCounts = interface.countRoleAssignments()
|
|
||||||
|
|
||||||
def _computeScopeType(role) -> str:
|
|
||||||
if role.mandateId:
|
|
||||||
return "mandate"
|
|
||||||
if role.isSystemRole:
|
|
||||||
return "system"
|
|
||||||
return "global"
|
|
||||||
|
|
||||||
result = []
|
|
||||||
for role in dbRoles:
|
|
||||||
if role.featureInstanceId is not None:
|
|
||||||
continue
|
|
||||||
if mandateId:
|
|
||||||
if role.mandateId != mandateId:
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
if role.mandateId is not None:
|
|
||||||
continue
|
|
||||||
if not includeTemplates and role.featureCode is not None:
|
|
||||||
continue
|
|
||||||
scopeType = _computeScopeType(role)
|
|
||||||
if scopeFilter and scopeFilter != 'all':
|
|
||||||
if scopeFilter == 'mandate' and scopeType != 'mandate':
|
|
||||||
continue
|
|
||||||
if scopeFilter == 'global' and scopeType not in ('global', 'system'):
|
|
||||||
continue
|
|
||||||
if scopeFilter == 'system' and scopeType != 'system':
|
|
||||||
continue
|
|
||||||
result.append({
|
|
||||||
"id": role.id,
|
|
||||||
"roleLabel": role.roleLabel,
|
|
||||||
"description": resolveText(role.description),
|
|
||||||
"mandateId": role.mandateId,
|
|
||||||
"featureInstanceId": role.featureInstanceId,
|
|
||||||
"featureCode": role.featureCode,
|
|
||||||
"userCount": roleCounts.get(str(role.id), 0),
|
|
||||||
"isSystemRole": role.isSystemRole,
|
|
||||||
"scopeType": scopeType
|
|
||||||
})
|
|
||||||
if not isSysAdmin:
|
|
||||||
result = [r for r in result if r.get("mandateId") and str(r["mandateId"]) in adminMandateIds]
|
|
||||||
return _handleFilterValuesRequest(result, column, pagination)
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting filter values for roles: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/roles", response_model=Dict[str, Any])
|
@router.post("/roles", response_model=Dict[str, Any])
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
def create_role(
|
def create_role(
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ from modules.serviceCenter.services.serviceBilling.mainServiceBilling import get
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
|
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
|
||||||
from modules.routes.routeDataUsers import _applyFiltersAndSort, _extractDistinctValues, _handleFilterValuesRequest
|
|
||||||
from modules.datamodels.datamodelBilling import (
|
from modules.datamodels.datamodelBilling import (
|
||||||
BillingAccount,
|
BillingAccount,
|
||||||
BillingTransaction,
|
BillingTransaction,
|
||||||
|
|
@ -1707,6 +1706,8 @@ def getUserViewTransactions(
|
||||||
scope: str = Query(default="all", description="Scope: 'personal' (own costs only), 'mandate' (filter by mandateId), 'all' (RBAC-filtered)"),
|
scope: str = Query(default="all", description="Scope: 'personal' (own costs only), 'mandate' (filter by mandateId), 'all' (RBAC-filtered)"),
|
||||||
mandateId: Optional[str] = Query(None, description="Mandate ID filter (used with scope='mandate')"),
|
mandateId: Optional[str] = Query(None, description="Mandate ID filter (used with scope='mandate')"),
|
||||||
onlyMine: Optional[bool] = Query(None, description="Additional filter: restrict to current user's transactions within the selected scope"),
|
onlyMine: Optional[bool] = Query(None, description="Additional filter: restrict to current user's transactions within the selected scope"),
|
||||||
|
mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"),
|
||||||
|
column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"),
|
||||||
ctx: RequestContext = Depends(getRequestContext)
|
ctx: RequestContext = Depends(getRequestContext)
|
||||||
) -> PaginatedResponse[UserTransactionResponse]:
|
) -> PaginatedResponse[UserTransactionResponse]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -1726,16 +1727,10 @@ def getUserViewTransactions(
|
||||||
- mandateId: required when scope='mandate'
|
- mandateId: required when scope='mandate'
|
||||||
- onlyMine: true to restrict to current user's data within the scope
|
- onlyMine: true to restrict to current user's data within the scope
|
||||||
"""
|
"""
|
||||||
|
from modules.routes.routeHelpers import parseCrossFilterPagination
|
||||||
|
|
||||||
try:
|
try:
|
||||||
billingInterface = getBillingInterface(ctx.user, ctx.mandateId)
|
billingInterface = getBillingInterface(ctx.user, ctx.mandateId)
|
||||||
|
|
||||||
paginationParams = None
|
|
||||||
if pagination:
|
|
||||||
import json
|
|
||||||
paginationDict = json.loads(pagination)
|
|
||||||
paginationDict = normalize_pagination_dict(paginationDict)
|
|
||||||
paginationParams = PaginationParams(**paginationDict)
|
|
||||||
|
|
||||||
rbacScope = _getBillingDataScope(ctx.user)
|
rbacScope = _getBillingDataScope(ctx.user)
|
||||||
|
|
||||||
if rbacScope.isGlobalAdmin:
|
if rbacScope.isGlobalAdmin:
|
||||||
|
|
@ -1743,14 +1738,54 @@ def getUserViewTransactions(
|
||||||
else:
|
else:
|
||||||
loadMandateIds = rbacScope.adminMandateIds + rbacScope.memberMandateIds
|
loadMandateIds = rbacScope.adminMandateIds + rbacScope.memberMandateIds
|
||||||
if not loadMandateIds:
|
if not loadMandateIds:
|
||||||
|
if mode:
|
||||||
|
return []
|
||||||
return PaginatedResponse(items=[], pagination=None)
|
return PaginatedResponse(items=[], pagination=None)
|
||||||
|
|
||||||
if scope == "mandate" and mandateId:
|
if scope == "mandate" and mandateId:
|
||||||
loadMandateIds = [mandateId]
|
loadMandateIds = [mandateId]
|
||||||
|
|
||||||
effectiveScope = scope
|
|
||||||
personalUserId = str(ctx.user.id) if (scope == "personal" or onlyMine) else None
|
personalUserId = str(ctx.user.id) if (scope == "personal" or onlyMine) else None
|
||||||
|
|
||||||
|
if mode == "filterValues":
|
||||||
|
if not column:
|
||||||
|
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
crossFilterParams = parseCrossFilterPagination(column, pagination)
|
||||||
|
values = billingInterface.getTransactionDistinctValues(
|
||||||
|
mandateIds=loadMandateIds,
|
||||||
|
column=column,
|
||||||
|
pagination=crossFilterParams,
|
||||||
|
scope=scope,
|
||||||
|
userId=personalUserId,
|
||||||
|
)
|
||||||
|
return JSONResponse(content=values)
|
||||||
|
|
||||||
|
if mode == "ids":
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
paginationParams = None
|
||||||
|
if pagination:
|
||||||
|
import json as _json
|
||||||
|
paginationDict = _json.loads(pagination)
|
||||||
|
paginationDict = normalize_pagination_dict(paginationDict)
|
||||||
|
paginationParams = PaginationParams(**paginationDict)
|
||||||
|
ids = billingInterface.getTransactionIds(
|
||||||
|
mandateIds=loadMandateIds,
|
||||||
|
pagination=paginationParams,
|
||||||
|
scope=scope,
|
||||||
|
userId=personalUserId,
|
||||||
|
) if hasattr(billingInterface, 'getTransactionIds') else []
|
||||||
|
return JSONResponse(content=ids)
|
||||||
|
|
||||||
|
paginationParams = None
|
||||||
|
if pagination:
|
||||||
|
import json as _json
|
||||||
|
paginationDict = _json.loads(pagination)
|
||||||
|
paginationDict = normalize_pagination_dict(paginationDict)
|
||||||
|
paginationParams = PaginationParams(**paginationDict)
|
||||||
|
|
||||||
|
effectiveScope = scope
|
||||||
|
|
||||||
if not paginationParams:
|
if not paginationParams:
|
||||||
paginationParams = PaginationParams(page=1, pageSize=50)
|
paginationParams = PaginationParams(page=1, pageSize=50)
|
||||||
|
|
||||||
|
|
@ -1800,58 +1835,3 @@ def getUserViewTransactions(
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting user view transactions: {e}")
|
logger.error(f"Error getting user view transactions: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/view/users/transactions/filter-values")
|
|
||||||
@limiter.limit("60/minute")
|
|
||||||
def getUserViewTransactionsFilterValues(
|
|
||||||
request: Request,
|
|
||||||
column: str = Query(..., description="Column key"),
|
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded current filters"),
|
|
||||||
scope: str = Query(default="all", description="Scope: 'personal', 'mandate', 'all'"),
|
|
||||||
mandateId: Optional[str] = Query(None, description="Mandate ID filter (used with scope='mandate')"),
|
|
||||||
onlyMine: Optional[bool] = Query(None, description="Additional filter: restrict to current user's data within the selected scope"),
|
|
||||||
ctx: RequestContext = Depends(getRequestContext)
|
|
||||||
):
|
|
||||||
"""Return distinct filter values for a column in user transactions (SQL DISTINCT)."""
|
|
||||||
try:
|
|
||||||
billingInterface = getBillingInterface(ctx.user, ctx.mandateId)
|
|
||||||
rbacScope = _getBillingDataScope(ctx.user)
|
|
||||||
|
|
||||||
if rbacScope.isGlobalAdmin:
|
|
||||||
loadMandateIds = None
|
|
||||||
else:
|
|
||||||
loadMandateIds = rbacScope.adminMandateIds + rbacScope.memberMandateIds
|
|
||||||
if not loadMandateIds:
|
|
||||||
return []
|
|
||||||
|
|
||||||
if scope == "mandate" and mandateId:
|
|
||||||
loadMandateIds = [mandateId]
|
|
||||||
|
|
||||||
crossFilterParams = None
|
|
||||||
if pagination:
|
|
||||||
try:
|
|
||||||
import json
|
|
||||||
paginationDict = json.loads(pagination)
|
|
||||||
if paginationDict:
|
|
||||||
paginationDict = normalize_pagination_dict(paginationDict)
|
|
||||||
filters = paginationDict.get("filters", {})
|
|
||||||
filters.pop(column, None)
|
|
||||||
paginationDict["filters"] = filters
|
|
||||||
paginationDict.pop("sort", None)
|
|
||||||
crossFilterParams = PaginationParams(**paginationDict)
|
|
||||||
except (json.JSONDecodeError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
personalUserId = str(ctx.user.id) if (scope == "personal" or onlyMine) else None
|
|
||||||
|
|
||||||
return billingInterface.getTransactionDistinctValues(
|
|
||||||
mandateIds=loadMandateIds,
|
|
||||||
column=column,
|
|
||||||
pagination=crossFilterParams,
|
|
||||||
scope=scope,
|
|
||||||
userId=personalUserId,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting filter values for user transactions: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,8 @@ def get_auth_authority_options(
|
||||||
async def get_connections(
|
async def get_connections(
|
||||||
request: Request,
|
request: Request,
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||||
|
mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"),
|
||||||
|
column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
currentUser: User = Depends(getCurrentUser)
|
||||||
) -> PaginatedResponse[UserConnection]:
|
) -> PaginatedResponse[UserConnection]:
|
||||||
"""Get connections for the current user with optional pagination, sorting, and filtering.
|
"""Get connections for the current user with optional pagination, sorting, and filtering.
|
||||||
|
|
@ -146,7 +148,49 @@ async def get_connections(
|
||||||
- GET /api/connections/ (no pagination - returns all items)
|
- GET /api/connections/ (no pagination - returns all items)
|
||||||
- GET /api/connections/?pagination={"page":1,"pageSize":10,"sort":[]}
|
- GET /api/connections/?pagination={"page":1,"pageSize":10,"sort":[]}
|
||||||
- GET /api/connections/?pagination={"page":1,"pageSize":10,"filters":{"status":"active"}}
|
- GET /api/connections/?pagination={"page":1,"pageSize":10,"filters":{"status":"active"}}
|
||||||
|
- GET /api/connections/?mode=filterValues&column=status
|
||||||
|
- GET /api/connections/?mode=ids
|
||||||
"""
|
"""
|
||||||
|
from modules.routes.routeHelpers import handleFilterValuesInMemory, handleIdsInMemory
|
||||||
|
|
||||||
|
def _buildEnhancedItems():
|
||||||
|
interface = getInterface(currentUser)
|
||||||
|
connections = interface.getUserConnections(currentUser.id)
|
||||||
|
items = []
|
||||||
|
for connection in connections:
|
||||||
|
tokenStatus, tokenExpiresAt = getTokenStatusForConnection(interface, connection.id)
|
||||||
|
items.append({
|
||||||
|
"id": connection.id,
|
||||||
|
"userId": connection.userId,
|
||||||
|
"authority": connection.authority.value if hasattr(connection.authority, 'value') else str(connection.authority),
|
||||||
|
"externalId": connection.externalId,
|
||||||
|
"externalUsername": connection.externalUsername or "",
|
||||||
|
"externalEmail": connection.externalEmail,
|
||||||
|
"status": connection.status.value if hasattr(connection.status, 'value') else str(connection.status),
|
||||||
|
"connectedAt": connection.connectedAt,
|
||||||
|
"lastChecked": connection.lastChecked,
|
||||||
|
"expiresAt": connection.expiresAt,
|
||||||
|
"tokenStatus": tokenStatus,
|
||||||
|
"tokenExpiresAt": tokenExpiresAt
|
||||||
|
})
|
||||||
|
return items
|
||||||
|
|
||||||
|
if mode == "filterValues":
|
||||||
|
if not column:
|
||||||
|
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
||||||
|
try:
|
||||||
|
return handleFilterValuesInMemory(_buildEnhancedItems(), column, pagination)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting filter values for connections: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
if mode == "ids":
|
||||||
|
try:
|
||||||
|
return handleIdsInMemory(_buildEnhancedItems(), pagination)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting IDs for connections: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
interface = getInterface(currentUser)
|
interface = getInterface(currentUser)
|
||||||
|
|
||||||
|
|
@ -295,42 +339,6 @@ async def get_connections(
|
||||||
detail=f"Failed to get connections: {str(e)}"
|
detail=f"Failed to get connections: {str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.get("/filter-values")
|
|
||||||
@limiter.limit("60/minute")
|
|
||||||
def get_connection_filter_values(
|
|
||||||
request: Request,
|
|
||||||
column: str = Query(..., description="Column key"),
|
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded current filters"),
|
|
||||||
currentUser: User = Depends(getCurrentUser)
|
|
||||||
) -> List[str]:
|
|
||||||
"""Return distinct filter values for a column in connections."""
|
|
||||||
try:
|
|
||||||
from modules.routes.routeDataUsers import _handleFilterValuesRequest
|
|
||||||
interface = getInterface(currentUser)
|
|
||||||
connections = interface.getUserConnections(currentUser.id)
|
|
||||||
items = []
|
|
||||||
for connection in connections:
|
|
||||||
tokenStatus, tokenExpiresAt = getTokenStatusForConnection(interface, connection.id)
|
|
||||||
items.append({
|
|
||||||
"id": connection.id,
|
|
||||||
"userId": connection.userId,
|
|
||||||
"authority": connection.authority.value if hasattr(connection.authority, 'value') else str(connection.authority),
|
|
||||||
"externalId": connection.externalId,
|
|
||||||
"externalUsername": connection.externalUsername or "",
|
|
||||||
"externalEmail": connection.externalEmail,
|
|
||||||
"status": connection.status.value if hasattr(connection.status, 'value') else str(connection.status),
|
|
||||||
"connectedAt": connection.connectedAt,
|
|
||||||
"lastChecked": connection.lastChecked,
|
|
||||||
"expiresAt": connection.expiresAt,
|
|
||||||
"tokenStatus": tokenStatus,
|
|
||||||
"tokenExpiresAt": tokenExpiresAt
|
|
||||||
})
|
|
||||||
return _handleFilterValuesRequest(items, column, pagination)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting filter values for connections: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", response_model=UserConnection)
|
@router.post("/", response_model=UserConnection)
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
def create_connection(
|
def create_connection(
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,8 @@ router = APIRouter(
|
||||||
def get_files(
|
def get_files(
|
||||||
request: Request,
|
request: Request,
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||||
|
mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"),
|
||||||
|
column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"),
|
||||||
currentUser: User = Depends(getCurrentUser),
|
currentUser: User = Depends(getCurrentUser),
|
||||||
context: RequestContext = Depends(getRequestContext)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> PaginatedResponse[FileItem]:
|
) -> PaginatedResponse[FileItem]:
|
||||||
|
|
@ -207,20 +209,45 @@ def get_files(
|
||||||
detail=f"Invalid pagination parameter: {str(e)}"
|
detail=f"Invalid pagination parameter: {str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
recordFilter = None
|
from modules.routes.routeHelpers import (
|
||||||
if paginationParams and paginationParams.filters and "folderId" in paginationParams.filters:
|
handleFilterValuesInMemory,
|
||||||
fVal = paginationParams.filters.pop("folderId")
|
handleIdsMode,
|
||||||
recordFilter = {"folderId": fVal}
|
parseCrossFilterPagination,
|
||||||
|
)
|
||||||
|
|
||||||
managementInterface = interfaceDbManagement.getInterface(
|
managementInterface = interfaceDbManagement.getInterface(
|
||||||
currentUser,
|
currentUser,
|
||||||
mandateId=str(context.mandateId) if context.mandateId else None,
|
mandateId=str(context.mandateId) if context.mandateId else None,
|
||||||
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
|
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if mode == "filterValues":
|
||||||
|
if not column:
|
||||||
|
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
||||||
|
crossPagination = parseCrossFilterPagination(column, pagination)
|
||||||
|
recordFilter = {"sysCreatedBy": managementInterface.userId}
|
||||||
|
try:
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
values = managementInterface.db.getDistinctColumnValues(
|
||||||
|
FileItem, column, crossPagination, recordFilter
|
||||||
|
)
|
||||||
|
return JSONResponse(content=sorted(values, key=lambda v: str(v).lower()))
|
||||||
|
except Exception:
|
||||||
|
result = managementInterface.getAllFiles(pagination=None)
|
||||||
|
items = [r.model_dump() if hasattr(r, 'model_dump') else r for r in result]
|
||||||
|
return handleFilterValuesInMemory(items, column, pagination)
|
||||||
|
|
||||||
|
if mode == "ids":
|
||||||
|
recordFilter = {"sysCreatedBy": managementInterface.userId}
|
||||||
|
return handleIdsMode(managementInterface.db, FileItem, pagination, recordFilter)
|
||||||
|
|
||||||
|
recordFilter = None
|
||||||
|
if paginationParams and paginationParams.filters and "folderId" in paginationParams.filters:
|
||||||
|
fVal = paginationParams.filters.pop("folderId")
|
||||||
|
recordFilter = {"folderId": fVal}
|
||||||
|
|
||||||
result = managementInterface.getAllFiles(pagination=paginationParams, recordFilter=recordFilter)
|
result = managementInterface.getAllFiles(pagination=paginationParams, recordFilter=recordFilter)
|
||||||
|
|
||||||
# If pagination was requested, result is PaginatedResult
|
|
||||||
# If no pagination, result is List[FileItem]
|
|
||||||
if paginationParams:
|
if paginationParams:
|
||||||
return PaginatedResponse(
|
return PaginatedResponse(
|
||||||
items=result.items,
|
items=result.items,
|
||||||
|
|
@ -247,55 +274,6 @@ def get_files(
|
||||||
detail=f"Failed to get files: {str(e)}"
|
detail=f"Failed to get files: {str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.get("/list/filter-values")
|
|
||||||
@limiter.limit("60/minute")
|
|
||||||
def get_file_filter_values(
|
|
||||||
request: Request,
|
|
||||||
column: str = Query(..., description="Column key"),
|
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded current filters"),
|
|
||||||
currentUser: User = Depends(getCurrentUser),
|
|
||||||
context: RequestContext = Depends(getRequestContext)
|
|
||||||
) -> list:
|
|
||||||
"""Return distinct filter values for a column in files."""
|
|
||||||
try:
|
|
||||||
managementInterface = interfaceDbManagement.getInterface(
|
|
||||||
currentUser,
|
|
||||||
mandateId=str(context.mandateId) if context.mandateId else None,
|
|
||||||
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
|
|
||||||
)
|
|
||||||
|
|
||||||
crossFilterPagination = None
|
|
||||||
if pagination:
|
|
||||||
try:
|
|
||||||
paginationDict = json.loads(pagination)
|
|
||||||
if paginationDict:
|
|
||||||
paginationDict = normalize_pagination_dict(paginationDict)
|
|
||||||
filters = paginationDict.get("filters", {})
|
|
||||||
filters.pop(column, None)
|
|
||||||
paginationDict["filters"] = filters
|
|
||||||
paginationDict.pop("sort", None)
|
|
||||||
crossFilterPagination = PaginationParams(**paginationDict)
|
|
||||||
except (json.JSONDecodeError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
recordFilter = {"sysCreatedBy": managementInterface.userId}
|
|
||||||
values = managementInterface.db.getDistinctColumnValues(
|
|
||||||
FileItem, column, crossFilterPagination, recordFilter
|
|
||||||
)
|
|
||||||
return sorted(values, key=lambda v: str(v).lower())
|
|
||||||
except Exception:
|
|
||||||
from modules.routes.routeDataUsers import _handleFilterValuesRequest
|
|
||||||
result = managementInterface.getAllFiles(pagination=None)
|
|
||||||
items = [r.model_dump() if hasattr(r, 'model_dump') else r for r in result]
|
|
||||||
return _handleFilterValuesRequest(items, column, pagination)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting filter values for files: {str(e)}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail=str(e)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/upload", status_code=status.HTTP_201_CREATED)
|
@router.post("/upload", status_code=status.HTTP_201_CREATED)
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,8 @@ router = APIRouter(
|
||||||
def get_mandates(
|
def get_mandates(
|
||||||
request: Request,
|
request: Request,
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||||
|
mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"),
|
||||||
|
column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"),
|
||||||
context: RequestContext = Depends(getRequestContext)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> PaginatedResponse[Mandate]:
|
) -> PaginatedResponse[Mandate]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -122,13 +124,50 @@ def get_mandates(
|
||||||
detail=f"Invalid pagination parameter: {str(e)}"
|
detail=f"Invalid pagination parameter: {str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from modules.routes.routeHelpers import (
|
||||||
|
handleFilterValuesInMemory, handleIdsInMemory,
|
||||||
|
handleFilterValuesMode, handleIdsMode,
|
||||||
|
parseCrossFilterPagination,
|
||||||
|
)
|
||||||
|
|
||||||
appInterface = interfaceDbApp.getRootInterface()
|
appInterface = interfaceDbApp.getRootInterface()
|
||||||
|
|
||||||
|
if mode == "filterValues":
|
||||||
|
if not column:
|
||||||
|
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
||||||
|
if isSysAdmin:
|
||||||
|
crossPagination = parseCrossFilterPagination(column, pagination)
|
||||||
|
try:
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
values = appInterface.db.getDistinctColumnValues(Mandate, column, crossPagination)
|
||||||
|
return JSONResponse(content=sorted(values, key=lambda v: str(v).lower()))
|
||||||
|
except Exception:
|
||||||
|
result = appInterface.getAllMandates(pagination=None)
|
||||||
|
items = result if isinstance(result, list) else (result.items if hasattr(result, 'items') else result)
|
||||||
|
items = [i.model_dump() if hasattr(i, 'model_dump') else i for i in items]
|
||||||
|
return handleFilterValuesInMemory(items, column, pagination)
|
||||||
|
else:
|
||||||
|
mandateItems = []
|
||||||
|
for mid in adminMandateIds:
|
||||||
|
m = appInterface.getMandate(mid)
|
||||||
|
if m and getattr(m, "enabled", True):
|
||||||
|
mandateItems.append(m.model_dump() if hasattr(m, 'model_dump') else m if isinstance(m, dict) else vars(m))
|
||||||
|
return handleFilterValuesInMemory(mandateItems, column, pagination)
|
||||||
|
|
||||||
|
if mode == "ids":
|
||||||
|
if isSysAdmin:
|
||||||
|
return handleIdsMode(appInterface.db, Mandate, pagination)
|
||||||
|
else:
|
||||||
|
mandateItems = []
|
||||||
|
for mid in adminMandateIds:
|
||||||
|
m = appInterface.getMandate(mid)
|
||||||
|
if m and getattr(m, "enabled", True):
|
||||||
|
mandateItems.append(m.model_dump() if hasattr(m, 'model_dump') else m if isinstance(m, dict) else vars(m))
|
||||||
|
return handleIdsInMemory(mandateItems, pagination)
|
||||||
|
|
||||||
if isSysAdmin:
|
if isSysAdmin:
|
||||||
# SysAdmin: all mandates
|
|
||||||
result = appInterface.getAllMandates(pagination=paginationParams)
|
result = appInterface.getAllMandates(pagination=paginationParams)
|
||||||
else:
|
else:
|
||||||
# MandateAdmin: only their enabled mandates
|
|
||||||
allMandates = []
|
allMandates = []
|
||||||
for mandateId in adminMandateIds:
|
for mandateId in adminMandateIds:
|
||||||
mandate = appInterface.getMandate(mandateId)
|
mandate = appInterface.getMandate(mandateId)
|
||||||
|
|
@ -136,10 +175,8 @@ def get_mandates(
|
||||||
mandateDict = mandate if isinstance(mandate, dict) else mandate.model_dump() if hasattr(mandate, 'model_dump') else vars(mandate)
|
mandateDict = mandate if isinstance(mandate, dict) else mandate.model_dump() if hasattr(mandate, 'model_dump') else vars(mandate)
|
||||||
allMandates.append(mandateDict)
|
allMandates.append(mandateDict)
|
||||||
result = allMandates
|
result = allMandates
|
||||||
paginationParams = None # Client-side pagination for filtered results
|
paginationParams = None
|
||||||
|
|
||||||
# If pagination was requested, result is PaginatedResult
|
|
||||||
# If no pagination, result is List[Mandate]
|
|
||||||
if paginationParams and hasattr(result, 'items'):
|
if paginationParams and hasattr(result, 'items'):
|
||||||
return PaginatedResponse(
|
return PaginatedResponse(
|
||||||
items=result.items,
|
items=result.items,
|
||||||
|
|
@ -167,65 +204,6 @@ def get_mandates(
|
||||||
detail=f"Failed to get mandates: {str(e)}"
|
detail=f"Failed to get mandates: {str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.get("/filter-values")
|
|
||||||
@limiter.limit("60/minute")
|
|
||||||
def get_mandate_filter_values(
|
|
||||||
request: Request,
|
|
||||||
column: str = Query(..., description="Column key"),
|
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded current filters"),
|
|
||||||
context: RequestContext = Depends(getRequestContext)
|
|
||||||
) -> list:
|
|
||||||
"""Return distinct filter values for a column in mandates."""
|
|
||||||
try:
|
|
||||||
from modules.routes.routeDataUsers import _handleFilterValuesRequest
|
|
||||||
isSysAdmin = context.hasSysAdminRole
|
|
||||||
if not isSysAdmin:
|
|
||||||
adminMandateIds = _getAdminMandateIds(context)
|
|
||||||
if not adminMandateIds:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Admin role required"))
|
|
||||||
|
|
||||||
appInterface = interfaceDbApp.getRootInterface()
|
|
||||||
|
|
||||||
if isSysAdmin:
|
|
||||||
# SysAdmin: try SQL DISTINCT for DB columns
|
|
||||||
crossFilterPagination = None
|
|
||||||
if pagination:
|
|
||||||
try:
|
|
||||||
paginationDict = json.loads(pagination)
|
|
||||||
if paginationDict:
|
|
||||||
paginationDict = normalize_pagination_dict(paginationDict)
|
|
||||||
filters = paginationDict.get("filters", {})
|
|
||||||
filters.pop(column, None)
|
|
||||||
paginationDict["filters"] = filters
|
|
||||||
paginationDict.pop("sort", None)
|
|
||||||
crossFilterPagination = PaginationParams(**paginationDict)
|
|
||||||
except (json.JSONDecodeError, ValueError):
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
values = appInterface.db.getDistinctColumnValues(
|
|
||||||
Mandate, column, crossFilterPagination
|
|
||||||
)
|
|
||||||
return sorted(values, key=lambda v: str(v).lower())
|
|
||||||
except Exception:
|
|
||||||
result = appInterface.getAllMandates(pagination=None)
|
|
||||||
items = result if isinstance(result, list) else (result.items if hasattr(result, 'items') else result)
|
|
||||||
items = [i.model_dump() if hasattr(i, 'model_dump') else i for i in items]
|
|
||||||
return _handleFilterValuesRequest(items, column, pagination)
|
|
||||||
else:
|
|
||||||
# MandateAdmin: in-memory (small set of individual mandate lookups)
|
|
||||||
result = []
|
|
||||||
for mid in adminMandateIds:
|
|
||||||
mandate = appInterface.getMandate(mid)
|
|
||||||
if mandate:
|
|
||||||
result.append(mandate if isinstance(mandate, dict) else mandate.model_dump() if hasattr(mandate, 'model_dump') else vars(mandate))
|
|
||||||
items = [i.model_dump() if hasattr(i, 'model_dump') else i for i in result]
|
|
||||||
return _handleFilterValuesRequest(items, column, pagination)
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting filter values for mandates: {str(e)}")
|
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{targetMandateId}", response_model=Mandate)
|
@router.get("/{targetMandateId}", response_model=Mandate)
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
|
|
@ -475,6 +453,8 @@ def list_mandate_users(
|
||||||
request: Request,
|
request: Request,
|
||||||
targetMandateId: str = Path(..., description="ID of the mandate"),
|
targetMandateId: str = Path(..., description="ID of the mandate"),
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||||
|
mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"),
|
||||||
|
column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"),
|
||||||
context: RequestContext = Depends(getRequestContext)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
|
|
@ -556,7 +536,7 @@ def list_mandate_users(
|
||||||
continue
|
continue
|
||||||
|
|
||||||
result.append({
|
result.append({
|
||||||
"id": str(um.id), # UserMandate ID as primary key
|
"id": str(um.id),
|
||||||
"userId": str(user.id),
|
"userId": str(user.id),
|
||||||
"username": user.username,
|
"username": user.username,
|
||||||
"email": user.email,
|
"email": user.email,
|
||||||
|
|
@ -566,57 +546,40 @@ def list_mandate_users(
|
||||||
"enabled": um.enabled
|
"enabled": um.enabled
|
||||||
})
|
})
|
||||||
|
|
||||||
# Apply search, filtering, and sorting if pagination requested
|
from modules.routes.routeHelpers import (
|
||||||
if paginationParams:
|
handleFilterValuesInMemory, handleIdsInMemory,
|
||||||
# Apply search (if search term provided)
|
_applyFiltersAndSort as _sharedApplyFiltersAndSort,
|
||||||
searchTerm = paginationParams.get('search', '').lower() if paginationParams.get('search') else ''
|
paginateInMemory,
|
||||||
if searchTerm:
|
|
||||||
searchedResult = []
|
|
||||||
for item in result:
|
|
||||||
username = (item.get("username") or "").lower()
|
|
||||||
email = (item.get("email") or "").lower()
|
|
||||||
fullName = (item.get("fullName") or "").lower()
|
|
||||||
roleLabelsStr = " ".join(item.get("roleLabels") or []).lower()
|
|
||||||
|
|
||||||
if searchTerm in username or searchTerm in email or searchTerm in fullName or searchTerm in roleLabelsStr:
|
|
||||||
searchedResult.append(item)
|
|
||||||
result = searchedResult
|
|
||||||
|
|
||||||
# Apply filters (if filters provided)
|
|
||||||
filters = paginationParams.get('filters')
|
|
||||||
if filters:
|
|
||||||
for fieldName, filterValue in filters.items():
|
|
||||||
if filterValue is not None and filterValue != '':
|
|
||||||
filterValueLower = str(filterValue).lower()
|
|
||||||
result = [
|
|
||||||
item for item in result
|
|
||||||
if str(item.get(fieldName, '')).lower() == filterValueLower
|
|
||||||
]
|
|
||||||
|
|
||||||
# Apply sorting
|
|
||||||
sortFields = paginationParams.get('sort')
|
|
||||||
if sortFields:
|
|
||||||
for sortItem in reversed(sortFields):
|
|
||||||
field = sortItem.get('field')
|
|
||||||
direction = sortItem.get('direction', 'asc')
|
|
||||||
if field:
|
|
||||||
result = sorted(
|
|
||||||
result,
|
|
||||||
key=lambda x: str(x.get(field, '') or '').lower(),
|
|
||||||
reverse=(direction == 'desc')
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Apply pagination
|
if mode == "filterValues":
|
||||||
page = paginationParams.get('page', 1)
|
if not column:
|
||||||
pageSize = paginationParams.get('pageSize', 25)
|
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
||||||
totalItems = len(result)
|
return handleFilterValuesInMemory(result, column, pagination)
|
||||||
|
|
||||||
|
if mode == "ids":
|
||||||
|
return handleIdsInMemory(result, pagination)
|
||||||
|
|
||||||
|
if paginationParams:
|
||||||
|
paginationParamsObj = None
|
||||||
|
try:
|
||||||
|
paginationDict = json.loads(pagination) if pagination else None
|
||||||
|
if paginationDict:
|
||||||
|
paginationDict = normalize_pagination_dict(paginationDict)
|
||||||
|
paginationParamsObj = PaginationParams(**paginationDict)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
filtered = _sharedApplyFiltersAndSort(result, paginationParamsObj)
|
||||||
|
totalItems = len(filtered)
|
||||||
|
page = paginationParams.get('page', 1) if isinstance(paginationParams, dict) else 1
|
||||||
|
pageSize = paginationParams.get('pageSize', 25) if isinstance(paginationParams, dict) else 25
|
||||||
totalPages = (totalItems + pageSize - 1) // pageSize if totalItems > 0 else 0
|
totalPages = (totalItems + pageSize - 1) // pageSize if totalItems > 0 else 0
|
||||||
startIdx = (page - 1) * pageSize
|
startIdx = (page - 1) * pageSize
|
||||||
endIdx = startIdx + pageSize
|
endIdx = startIdx + pageSize
|
||||||
paginatedResult = result[startIdx:endIdx]
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"items": paginatedResult,
|
"items": filtered[startIdx:endIdx],
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"currentPage": page,
|
"currentPage": page,
|
||||||
"pageSize": pageSize,
|
"pageSize": pageSize,
|
||||||
|
|
@ -625,7 +588,6 @@ def list_mandate_users(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# No pagination - return all users as list
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
@ -638,63 +600,6 @@ def list_mandate_users(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{targetMandateId}/users/filter-values")
|
|
||||||
@limiter.limit("60/minute")
|
|
||||||
def get_mandate_users_filter_values(
|
|
||||||
request: Request,
|
|
||||||
targetMandateId: str = Path(..., description="ID of the mandate"),
|
|
||||||
column: str = Query(..., description="Column key"),
|
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded current filters"),
|
|
||||||
context: RequestContext = Depends(getRequestContext)
|
|
||||||
) -> list:
|
|
||||||
"""Return distinct filter values for a column in mandate users."""
|
|
||||||
if not _hasMandateAdminRole(context, targetMandateId) and not context.hasSysAdminRole:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Mandate-Admin role required"))
|
|
||||||
|
|
||||||
try:
|
|
||||||
from modules.routes.routeDataUsers import _handleFilterValuesRequest
|
|
||||||
rootInterface = interfaceDbApp.getRootInterface()
|
|
||||||
mandate = rootInterface.getMandate(targetMandateId)
|
|
||||||
if not mandate:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Mandate {targetMandateId} not found")
|
|
||||||
|
|
||||||
userMandates = rootInterface.getUserMandatesByMandate(targetMandateId)
|
|
||||||
result = []
|
|
||||||
for um in userMandates:
|
|
||||||
user = rootInterface.getUser(str(um.userId))
|
|
||||||
if not user:
|
|
||||||
continue
|
|
||||||
roleIds = rootInterface.getRoleIdsForUserMandate(str(um.id))
|
|
||||||
roleLabels = []
|
|
||||||
filteredRoleIds = []
|
|
||||||
seenLabels = set()
|
|
||||||
for roleId in roleIds:
|
|
||||||
role = rootInterface.getRole(roleId)
|
|
||||||
if role:
|
|
||||||
if role.featureInstanceId:
|
|
||||||
continue
|
|
||||||
filteredRoleIds.append(roleId)
|
|
||||||
if role.roleLabel not in seenLabels:
|
|
||||||
roleLabels.append(role.roleLabel)
|
|
||||||
seenLabels.add(role.roleLabel)
|
|
||||||
result.append({
|
|
||||||
"id": str(um.id),
|
|
||||||
"userId": str(user.id),
|
|
||||||
"username": user.username,
|
|
||||||
"email": user.email,
|
|
||||||
"fullName": user.fullName,
|
|
||||||
"roleIds": filteredRoleIds,
|
|
||||||
"roleLabels": roleLabels,
|
|
||||||
"enabled": um.enabled
|
|
||||||
})
|
|
||||||
return _handleFilterValuesRequest(result, column, pagination)
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting filter values for mandate users: {str(e)}")
|
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{targetMandateId}/users", response_model=UserMandateResponse)
|
@router.post("/{targetMandateId}/users", response_model=UserMandateResponse)
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
def add_user_to_mandate(
|
def add_user_to_mandate(
|
||||||
|
|
|
||||||
|
|
@ -27,44 +27,52 @@ router = APIRouter(
|
||||||
responses={404: {"description": "Not found"}}
|
responses={404: {"description": "Not found"}}
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.get("", response_model=PaginatedResponse[Prompt])
|
@router.get("")
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
def get_prompts(
|
def get_prompts(
|
||||||
request: Request,
|
request: Request,
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||||
|
mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"),
|
||||||
|
column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
currentUser: User = Depends(getCurrentUser)
|
||||||
) -> PaginatedResponse[Prompt]:
|
):
|
||||||
"""
|
"""
|
||||||
Get prompts with optional pagination, sorting, and filtering.
|
Get prompts with optional pagination, sorting, and filtering.
|
||||||
|
|
||||||
Query Parameters:
|
Modes:
|
||||||
- pagination: JSON-encoded PaginationParams object, or None for no pagination
|
- None: paginated list (default)
|
||||||
|
- filterValues: distinct values for a column (cross-filtered)
|
||||||
Examples:
|
- ids: all IDs matching current filters
|
||||||
- GET /api/prompts (no pagination - returns all items)
|
|
||||||
- GET /api/prompts?pagination={"page":1,"pageSize":10,"sort":[]}
|
|
||||||
- GET /api/prompts?pagination={"page":2,"pageSize":20,"sort":[{"field":"name","direction":"asc"}]}
|
|
||||||
"""
|
"""
|
||||||
# Parse pagination parameter
|
from modules.routes.routeHelpers import handleFilterValuesInMemory, handleIdsInMemory
|
||||||
|
|
||||||
|
if mode == "filterValues":
|
||||||
|
if not column:
|
||||||
|
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
||||||
|
managementInterface = interfaceDbManagement.getInterface(currentUser)
|
||||||
|
result = managementInterface.getAllPrompts(pagination=None)
|
||||||
|
items = [r.model_dump() if hasattr(r, 'model_dump') else r for r in result]
|
||||||
|
return handleFilterValuesInMemory(items, column, pagination)
|
||||||
|
|
||||||
|
if mode == "ids":
|
||||||
|
managementInterface = interfaceDbManagement.getInterface(currentUser)
|
||||||
|
result = managementInterface.getAllPrompts(pagination=None)
|
||||||
|
items = [r.model_dump() if hasattr(r, 'model_dump') else r for r in result]
|
||||||
|
return handleIdsInMemory(items, pagination)
|
||||||
|
|
||||||
paginationParams = None
|
paginationParams = None
|
||||||
if pagination:
|
if pagination:
|
||||||
try:
|
try:
|
||||||
paginationDict = json.loads(pagination)
|
paginationDict = json.loads(pagination)
|
||||||
if paginationDict:
|
if paginationDict:
|
||||||
# Normalize pagination dict (handles top-level "search" field)
|
|
||||||
paginationDict = normalize_pagination_dict(paginationDict)
|
paginationDict = normalize_pagination_dict(paginationDict)
|
||||||
paginationParams = PaginationParams(**paginationDict)
|
paginationParams = PaginationParams(**paginationDict)
|
||||||
except (json.JSONDecodeError, ValueError) as e:
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}")
|
||||||
status_code=400,
|
|
||||||
detail=f"Invalid pagination parameter: {str(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
managementInterface = interfaceDbManagement.getInterface(currentUser)
|
managementInterface = interfaceDbManagement.getInterface(currentUser)
|
||||||
result = managementInterface.getAllPrompts(pagination=paginationParams)
|
result = managementInterface.getAllPrompts(pagination=paginationParams)
|
||||||
|
|
||||||
# If pagination was requested, result is PaginatedResult
|
|
||||||
# If no pagination, result is List[Prompt]
|
|
||||||
if paginationParams:
|
if paginationParams:
|
||||||
return PaginatedResponse(
|
return PaginatedResponse(
|
||||||
items=result.items,
|
items=result.items,
|
||||||
|
|
@ -83,28 +91,6 @@ def get_prompts(
|
||||||
pagination=None
|
pagination=None
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.get("/filter-values")
|
|
||||||
@limiter.limit("60/minute")
|
|
||||||
def get_prompt_filter_values(
|
|
||||||
request: Request,
|
|
||||||
column: str = Query(..., description="Column key"),
|
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded current filters"),
|
|
||||||
currentUser: User = Depends(getCurrentUser)
|
|
||||||
) -> list:
|
|
||||||
"""Return distinct filter values for a column in prompts.
|
|
||||||
|
|
||||||
NOTE: Cannot use db.getDistinctColumnValues() because visibility rules
|
|
||||||
(own + system for regular users) require pre-filtering the recordset.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from modules.routes.routeDataUsers import _handleFilterValuesRequest
|
|
||||||
managementInterface = interfaceDbManagement.getInterface(currentUser)
|
|
||||||
result = managementInterface.getAllPrompts(pagination=None)
|
|
||||||
items = [r.model_dump() if hasattr(r, 'model_dump') else r for r in result]
|
|
||||||
return _handleFilterValuesRequest(items, column, pagination)
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=Prompt)
|
@router.post("", response_model=Prompt)
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ from modules.auth import limiter, getRequestContext, RequestContext
|
||||||
from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority
|
from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
|
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
|
||||||
from modules.shared.i18nRegistry import apiRouteContext, resolveText
|
from modules.shared.i18nRegistry import apiRouteContext
|
||||||
routeApiMsg = apiRouteContext("routeDataUsers")
|
routeApiMsg = apiRouteContext("routeDataUsers")
|
||||||
|
|
||||||
# Configure logger
|
# Configure logger
|
||||||
|
|
@ -71,206 +71,72 @@ def _isAdminForUser(context: RequestContext, targetUserId: str) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _extractDistinctValues(
|
def _getUserFilterOrIds(context, paginationJson, column=None, idsMode=False):
|
||||||
items: List[Dict[str, Any]],
|
"""Unified handler for mode=filterValues and mode=ids across all user scoping branches."""
|
||||||
columnKey: str,
|
from modules.routes.routeHelpers import (
|
||||||
requestLang: Optional[str] = None,
|
handleFilterValuesInMemory, handleIdsInMemory,
|
||||||
) -> List[str]:
|
handleFilterValuesMode, handleIdsMode,
|
||||||
"""Extract sorted distinct display values for a column from enriched items."""
|
parseCrossFilterPagination,
|
||||||
values = set()
|
)
|
||||||
for item in items:
|
try:
|
||||||
val = item.get(columnKey)
|
appInterface = interfaceDbApp.getInterface(context.user, mandateId=context.mandateId)
|
||||||
if val is None or val == "":
|
requestLang = getattr(context.user, "language", None)
|
||||||
|
|
||||||
|
if context.mandateId:
|
||||||
|
result = appInterface.getUsersByMandate(str(context.mandateId), None)
|
||||||
|
users = result if isinstance(result, list) else (result.items if hasattr(result, 'items') else [])
|
||||||
|
items = [u.model_dump() if hasattr(u, 'model_dump') else u for u in users]
|
||||||
|
if idsMode:
|
||||||
|
return handleIdsInMemory(items, paginationJson)
|
||||||
|
return handleFilterValuesInMemory(items, column, paginationJson, requestLang)
|
||||||
|
|
||||||
|
if context.hasSysAdminRole:
|
||||||
|
rootInterface = getRootInterface()
|
||||||
|
if idsMode:
|
||||||
|
return handleIdsMode(rootInterface.db, UserInDB, paginationJson)
|
||||||
|
crossPagination = parseCrossFilterPagination(column, paginationJson)
|
||||||
|
try:
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
values = rootInterface.db.getDistinctColumnValues(UserInDB, column, crossPagination)
|
||||||
|
return JSONResponse(content=sorted(values, key=lambda v: v.lower()))
|
||||||
|
except Exception:
|
||||||
|
users = appInterface.getAllUsers()
|
||||||
|
items = [u.model_dump() if hasattr(u, 'model_dump') else u for u in users]
|
||||||
|
return handleFilterValuesInMemory(items, column, paginationJson, requestLang)
|
||||||
|
|
||||||
|
rootInterface = getRootInterface()
|
||||||
|
userMandates = rootInterface.getUserMandates(str(context.user.id))
|
||||||
|
adminMandateIds = []
|
||||||
|
for um in userMandates:
|
||||||
|
umId = getattr(um, 'id', None)
|
||||||
|
mandateId = getattr(um, 'mandateId', None)
|
||||||
|
if not umId or not mandateId:
|
||||||
continue
|
continue
|
||||||
if isinstance(val, bool):
|
roleIds = rootInterface.getRoleIdsForUserMandate(str(umId))
|
||||||
values.add("true" if val else "false")
|
for roleId in roleIds:
|
||||||
elif isinstance(val, (int, float)):
|
role = rootInterface.getRole(roleId)
|
||||||
values.add(str(val))
|
if role and role.roleLabel == "admin" and not role.featureInstanceId:
|
||||||
elif isinstance(val, dict):
|
adminMandateIds.append(str(mandateId))
|
||||||
text = resolveText(val, requestLang)
|
break
|
||||||
if text:
|
if not adminMandateIds:
|
||||||
values.add(text)
|
return []
|
||||||
else:
|
from modules.datamodels.datamodelMembership import UserMandate as UserMandateModel
|
||||||
values.add(str(val))
|
allUM = rootInterface.db.getRecordset(UserMandateModel, recordFilter={"mandateId": adminMandateIds})
|
||||||
return sorted(values, key=lambda v: v.lower())
|
uniqueUserIds = list({
|
||||||
|
(um.get("userId") if isinstance(um, dict) else getattr(um, "userId", None))
|
||||||
|
for um in (allUM or [])
|
||||||
def _handleFilterValuesRequest(
|
if (um.get("userId") if isinstance(um, dict) else getattr(um, "userId", None))
|
||||||
items: List[Dict[str, Any]],
|
})
|
||||||
column: str,
|
batchUsers = rootInterface.getUsersByIds(uniqueUserIds) if uniqueUserIds else {}
|
||||||
paginationJson: Optional[str] = None,
|
items = [u.model_dump() if hasattr(u, 'model_dump') else vars(u) for u in batchUsers.values()]
|
||||||
requestLang: Optional[str] = None,
|
if idsMode:
|
||||||
) -> List[str]:
|
return handleIdsInMemory(items, paginationJson)
|
||||||
"""
|
return handleFilterValuesInMemory(items, column, paginationJson, requestLang)
|
||||||
Generic handler for /filter-values endpoints.
|
except HTTPException:
|
||||||
Applies all active filters EXCEPT the one for the requested column (cross-filtering),
|
raise
|
||||||
then extracts distinct values for that column.
|
except Exception as e:
|
||||||
"""
|
logger.error(f"Error in _getUserFilterOrIds: {str(e)}")
|
||||||
crossFilterParams: Optional[PaginationParams] = None
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
if paginationJson:
|
|
||||||
try:
|
|
||||||
import json
|
|
||||||
paginationDict = json.loads(paginationJson)
|
|
||||||
if paginationDict:
|
|
||||||
paginationDict = normalize_pagination_dict(paginationDict)
|
|
||||||
filters = paginationDict.get("filters", {})
|
|
||||||
filters.pop(column, None)
|
|
||||||
paginationDict["filters"] = filters
|
|
||||||
paginationDict.pop("sort", None)
|
|
||||||
crossFilterParams = PaginationParams(**paginationDict)
|
|
||||||
except (json.JSONDecodeError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
crossFiltered = _applyFiltersAndSort(items, crossFilterParams)
|
|
||||||
return _extractDistinctValues(crossFiltered, column, requestLang)
|
|
||||||
|
|
||||||
|
|
||||||
def _applyFiltersAndSort(items: List[Dict[str, Any]], paginationParams: Optional[PaginationParams]) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Apply filters and sorting to a list of items.
|
|
||||||
This is used when we can't do server-side filtering in the database (e.g., SysAdmin view).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
items: List of dictionaries to filter/sort
|
|
||||||
paginationParams: Pagination parameters with filters and sort
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Filtered and sorted list
|
|
||||||
"""
|
|
||||||
if not paginationParams:
|
|
||||||
return items
|
|
||||||
|
|
||||||
result = items.copy()
|
|
||||||
|
|
||||||
# Apply filters
|
|
||||||
if paginationParams.filters:
|
|
||||||
filters = paginationParams.filters
|
|
||||||
|
|
||||||
# Handle general search
|
|
||||||
searchTerm = filters.get('search', '').lower() if filters.get('search') else None
|
|
||||||
|
|
||||||
if searchTerm:
|
|
||||||
def matchesSearch(item: Dict[str, Any]) -> bool:
|
|
||||||
for value in item.values():
|
|
||||||
if value is not None and searchTerm in str(value).lower():
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
result = [item for item in result if matchesSearch(item)]
|
|
||||||
|
|
||||||
# Handle field-specific filters
|
|
||||||
for field, filterValue in filters.items():
|
|
||||||
if field == 'search':
|
|
||||||
continue # Already handled
|
|
||||||
|
|
||||||
if isinstance(filterValue, dict) and 'operator' in filterValue:
|
|
||||||
operator = filterValue.get('operator', 'equals')
|
|
||||||
value = filterValue.get('value')
|
|
||||||
else:
|
|
||||||
operator = 'equals'
|
|
||||||
value = filterValue
|
|
||||||
|
|
||||||
if value is None or value == '':
|
|
||||||
continue
|
|
||||||
|
|
||||||
def matchesFilter(item: Dict[str, Any], f: str, op: str, v: Any) -> bool:
|
|
||||||
itemValue = item.get(f)
|
|
||||||
if itemValue is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
itemStr = str(itemValue).lower()
|
|
||||||
valueStr = str(v).lower()
|
|
||||||
|
|
||||||
if op in ('equals', 'eq'):
|
|
||||||
return itemStr == valueStr
|
|
||||||
elif op == 'contains':
|
|
||||||
return valueStr in itemStr
|
|
||||||
elif op == 'startsWith':
|
|
||||||
return itemStr.startswith(valueStr)
|
|
||||||
elif op == 'endsWith':
|
|
||||||
return itemStr.endswith(valueStr)
|
|
||||||
elif op in ('gt', 'gte', 'lt', 'lte'):
|
|
||||||
try:
|
|
||||||
itemNum = float(itemValue)
|
|
||||||
valueNum = float(v)
|
|
||||||
if op == 'gt':
|
|
||||||
return itemNum > valueNum
|
|
||||||
elif op == 'gte':
|
|
||||||
return itemNum >= valueNum
|
|
||||||
elif op == 'lt':
|
|
||||||
return itemNum < valueNum
|
|
||||||
elif op == 'lte':
|
|
||||||
return itemNum <= valueNum
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return False
|
|
||||||
elif op == 'between':
|
|
||||||
if isinstance(v, dict):
|
|
||||||
fromVal = v.get('from', '')
|
|
||||||
toVal = v.get('to', '')
|
|
||||||
if not fromVal and not toVal:
|
|
||||||
return True
|
|
||||||
# Date range: from/to are YYYY-MM-DD strings, itemValue may be Unix timestamp
|
|
||||||
try:
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
fromTs = None
|
|
||||||
toTs = None
|
|
||||||
if fromVal:
|
|
||||||
fromTs = datetime.strptime(str(fromVal), '%Y-%m-%d').replace(tzinfo=timezone.utc).timestamp()
|
|
||||||
if toVal:
|
|
||||||
toTs = datetime.strptime(str(toVal), '%Y-%m-%d').replace(hour=23, minute=59, second=59, tzinfo=timezone.utc).timestamp()
|
|
||||||
itemNum = float(itemValue) if not isinstance(itemValue, (int, float)) else itemValue
|
|
||||||
# Normalize: if item looks like a millisecond timestamp, convert to seconds
|
|
||||||
if itemNum > 10000000000:
|
|
||||||
itemNum = itemNum / 1000
|
|
||||||
if fromTs is not None and toTs is not None:
|
|
||||||
return fromTs <= itemNum <= toTs
|
|
||||||
elif fromTs is not None:
|
|
||||||
return itemNum >= fromTs
|
|
||||||
elif toTs is not None:
|
|
||||||
return itemNum <= toTs
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
# Fallback: string comparison (for non-numeric date fields)
|
|
||||||
fromStr = str(fromVal).lower() if fromVal else ''
|
|
||||||
toStr = str(toVal).lower() if toVal else ''
|
|
||||||
if fromStr and toStr:
|
|
||||||
return fromStr <= itemStr <= toStr
|
|
||||||
elif fromStr:
|
|
||||||
return itemStr >= fromStr
|
|
||||||
elif toStr:
|
|
||||||
return itemStr <= toStr
|
|
||||||
return True
|
|
||||||
elif op == 'in':
|
|
||||||
if isinstance(v, list):
|
|
||||||
return itemStr in [str(x).lower() for x in v]
|
|
||||||
return False
|
|
||||||
elif op == 'notIn':
|
|
||||||
if isinstance(v, list):
|
|
||||||
return itemStr not in [str(x).lower() for x in v]
|
|
||||||
return True
|
|
||||||
return True
|
|
||||||
|
|
||||||
result = [item for item in result if matchesFilter(item, field, operator, value)]
|
|
||||||
|
|
||||||
# Apply sorting — None values always last
|
|
||||||
if paginationParams.sort:
|
|
||||||
for sortField in reversed(paginationParams.sort):
|
|
||||||
fieldName = sortField.field
|
|
||||||
ascending = sortField.direction == 'asc'
|
|
||||||
|
|
||||||
noneItems = [item for item in result if item.get(fieldName) is None]
|
|
||||||
nonNoneItems = [item for item in result if item.get(fieldName) is not None]
|
|
||||||
|
|
||||||
def getSortKey(item: Dict[str, Any], _fn=fieldName):
|
|
||||||
value = item.get(_fn)
|
|
||||||
if isinstance(value, bool):
|
|
||||||
return (0, int(value), '')
|
|
||||||
if isinstance(value, (int, float)):
|
|
||||||
return (0, value, '')
|
|
||||||
return (1, 0, str(value).lower())
|
|
||||||
|
|
||||||
nonNoneItems = sorted(nonNoneItems, key=getSortKey, reverse=not ascending)
|
|
||||||
result = nonNoneItems + noneItems
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
|
|
@ -326,6 +192,8 @@ def get_user_options(
|
||||||
def get_users(
|
def get_users(
|
||||||
request: Request,
|
request: Request,
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||||
|
mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"),
|
||||||
|
column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"),
|
||||||
context: RequestContext = Depends(getRequestContext)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> PaginatedResponse[User]:
|
) -> PaginatedResponse[User]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -340,8 +208,15 @@ def get_users(
|
||||||
- GET /api/users/ (no pagination - returns all users in mandate)
|
- GET /api/users/ (no pagination - returns all users in mandate)
|
||||||
- GET /api/users/?pagination={"page":1,"pageSize":10,"sort":[]}
|
- GET /api/users/?pagination={"page":1,"pageSize":10,"sort":[]}
|
||||||
"""
|
"""
|
||||||
|
if mode == "filterValues":
|
||||||
|
if not column:
|
||||||
|
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
||||||
|
return _getUserFilterOrIds(context, pagination, column=column)
|
||||||
|
|
||||||
|
if mode == "ids":
|
||||||
|
return _getUserFilterOrIds(context, pagination, idsMode=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Parse pagination parameter
|
|
||||||
paginationParams = None
|
paginationParams = None
|
||||||
if pagination:
|
if pagination:
|
||||||
try:
|
try:
|
||||||
|
|
@ -357,8 +232,6 @@ def get_users(
|
||||||
|
|
||||||
appInterface = interfaceDbApp.getInterface(context.user, mandateId=context.mandateId)
|
appInterface = interfaceDbApp.getInterface(context.user, mandateId=context.mandateId)
|
||||||
|
|
||||||
# MULTI-TENANT: Use mandateId from context (header)
|
|
||||||
# SysAdmin without mandateId can see all users
|
|
||||||
if context.mandateId:
|
if context.mandateId:
|
||||||
# Get users for specific mandate using getUsersByMandate
|
# Get users for specific mandate using getUsersByMandate
|
||||||
result = appInterface.getUsersByMandate(str(context.mandateId), paginationParams)
|
result = appInterface.getUsersByMandate(str(context.mandateId), paginationParams)
|
||||||
|
|
@ -443,8 +316,8 @@ def get_users(
|
||||||
for u in batchUsers.values()
|
for u in batchUsers.values()
|
||||||
]
|
]
|
||||||
|
|
||||||
# Apply server-side filtering and sorting
|
from modules.routes.routeHelpers import _applyFiltersAndSort as _applyFiltersAndSortHelper
|
||||||
filteredUsers = _applyFiltersAndSort(allUsers, paginationParams)
|
filteredUsers = _applyFiltersAndSortHelper(allUsers, paginationParams)
|
||||||
users = [User(**u) for u in filteredUsers]
|
users = [User(**u) for u in filteredUsers]
|
||||||
|
|
||||||
if paginationParams:
|
if paginationParams:
|
||||||
|
|
@ -480,86 +353,6 @@ def get_users(
|
||||||
detail=f"Failed to get users: {str(e)}"
|
detail=f"Failed to get users: {str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.get("/filter-values")
|
|
||||||
@limiter.limit("60/minute")
|
|
||||||
def get_user_filter_values(
|
|
||||||
request: Request,
|
|
||||||
column: str = Query(..., description="Column key"),
|
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded current filters"),
|
|
||||||
context: RequestContext = Depends(getRequestContext)
|
|
||||||
) -> list:
|
|
||||||
"""Return distinct filter values for a column in users."""
|
|
||||||
try:
|
|
||||||
appInterface = interfaceDbApp.getInterface(context.user, mandateId=context.mandateId)
|
|
||||||
|
|
||||||
# Build cross-filter pagination (all filters except the requested column)
|
|
||||||
crossFilterPagination = None
|
|
||||||
if pagination:
|
|
||||||
try:
|
|
||||||
paginationDict = json.loads(pagination)
|
|
||||||
if paginationDict:
|
|
||||||
paginationDict = normalize_pagination_dict(paginationDict)
|
|
||||||
filters = paginationDict.get("filters", {})
|
|
||||||
filters.pop(column, None)
|
|
||||||
paginationDict["filters"] = filters
|
|
||||||
paginationDict.pop("sort", None)
|
|
||||||
crossFilterPagination = PaginationParams(**paginationDict)
|
|
||||||
except (json.JSONDecodeError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
if context.mandateId:
|
|
||||||
# Mandate-scoped: in-memory (users require UserMandate join)
|
|
||||||
result = appInterface.getUsersByMandate(str(context.mandateId), None)
|
|
||||||
users = result if isinstance(result, list) else (result.items if hasattr(result, 'items') else [])
|
|
||||||
items = [u.model_dump() if hasattr(u, 'model_dump') else u for u in users]
|
|
||||||
return _handleFilterValuesRequest(items, column, pagination, getattr(context.user, "language", None))
|
|
||||||
elif context.hasSysAdminRole:
|
|
||||||
# SysAdmin: use SQL DISTINCT for DB columns
|
|
||||||
try:
|
|
||||||
rootInterface = getRootInterface()
|
|
||||||
values = rootInterface.db.getDistinctColumnValues(
|
|
||||||
UserInDB, column, crossFilterPagination
|
|
||||||
)
|
|
||||||
return sorted(values, key=lambda v: v.lower())
|
|
||||||
except Exception:
|
|
||||||
users = appInterface.getAllUsers()
|
|
||||||
items = [u.model_dump() if hasattr(u, 'model_dump') else u for u in users]
|
|
||||||
return _handleFilterValuesRequest(items, column, pagination, getattr(context.user, "language", None))
|
|
||||||
else:
|
|
||||||
# Non-admin multi-mandate: aggregate across admin mandates (in-memory)
|
|
||||||
rootInterface = getRootInterface()
|
|
||||||
userMandates = rootInterface.getUserMandates(str(context.user.id))
|
|
||||||
adminMandateIds = []
|
|
||||||
for um in userMandates:
|
|
||||||
umId = getattr(um, 'id', None)
|
|
||||||
mandateId = getattr(um, 'mandateId', None)
|
|
||||||
if not umId or not mandateId:
|
|
||||||
continue
|
|
||||||
roleIds = rootInterface.getRoleIdsForUserMandate(str(umId))
|
|
||||||
for roleId in roleIds:
|
|
||||||
role = rootInterface.getRole(roleId)
|
|
||||||
if role and role.roleLabel == "admin" and not role.featureInstanceId:
|
|
||||||
adminMandateIds.append(str(mandateId))
|
|
||||||
break
|
|
||||||
if not adminMandateIds:
|
|
||||||
return []
|
|
||||||
from modules.datamodels.datamodelMembership import UserMandate as UserMandateModel
|
|
||||||
allUM = rootInterface.db.getRecordset(UserMandateModel, recordFilter={"mandateId": adminMandateIds})
|
|
||||||
uniqueUserIds = list({
|
|
||||||
(um.get("userId") if isinstance(um, dict) else getattr(um, "userId", None))
|
|
||||||
for um in (allUM or [])
|
|
||||||
if (um.get("userId") if isinstance(um, dict) else getattr(um, "userId", None))
|
|
||||||
})
|
|
||||||
batchUsers = rootInterface.getUsersByIds(uniqueUserIds) if uniqueUserIds else {}
|
|
||||||
items = [u.model_dump() if hasattr(u, 'model_dump') else vars(u) for u in batchUsers.values()]
|
|
||||||
return _handleFilterValuesRequest(items, column, pagination, getattr(context.user, "language", None))
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting filter values for users: {str(e)}")
|
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{userId}", response_model=User)
|
@router.get("/{userId}", response_model=User)
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
def get_user(
|
def get_user(
|
||||||
|
|
|
||||||
534
modules/routes/routeHelpers.py
Normal file
534
modules/routes/routeHelpers.py
Normal file
|
|
@ -0,0 +1,534 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""
|
||||||
|
Shared helpers for route handlers.
|
||||||
|
|
||||||
|
Provides unified logic for:
|
||||||
|
- mode=filterValues: distinct column values for filter dropdowns (cross-filtered)
|
||||||
|
- mode=ids: all IDs matching current filters (for bulk selection)
|
||||||
|
- In-memory equivalents for enriched/non-SQL routes
|
||||||
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, List, Optional, Callable
|
||||||
|
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from modules.datamodels.datamodelPagination import (
|
||||||
|
PaginationParams,
|
||||||
|
normalize_pagination_dict,
|
||||||
|
)
|
||||||
|
from modules.shared.i18nRegistry import resolveText
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Central FK label resolvers (cross-DB)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _resolveMandateLabels(ids: List[str]) -> Dict[str, str]:
|
||||||
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
|
rootIface = getRootInterface()
|
||||||
|
mMap = rootIface.getMandatesByIds(ids)
|
||||||
|
return {
|
||||||
|
mid: getattr(m, "label", None) or getattr(m, "name", mid) or mid
|
||||||
|
for mid, m in mMap.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolveInstanceLabels(ids: List[str]) -> Dict[str, str]:
|
||||||
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
|
from modules.interfaces.interfaceFeatures import getFeatureInterface
|
||||||
|
rootIface = getRootInterface()
|
||||||
|
featureIface = getFeatureInterface(rootIface.db)
|
||||||
|
result: Dict[str, str] = {}
|
||||||
|
for iid in ids:
|
||||||
|
fi = featureIface.getFeatureInstance(iid)
|
||||||
|
result[iid] = fi.label if fi and fi.label else iid
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _resolveUserLabels(ids: List[str]) -> Dict[str, str]:
|
||||||
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
|
rootIface = getRootInterface()
|
||||||
|
users = rootIface.db.getRecordset(
|
||||||
|
__import__("modules.datamodels.datamodelUam", fromlist=["User"]).User,
|
||||||
|
recordFilter={"id": list(set(ids))},
|
||||||
|
)
|
||||||
|
result: Dict[str, str] = {}
|
||||||
|
for u in (users or []):
|
||||||
|
uid = u.get("id", "")
|
||||||
|
result[uid] = u.get("username") or u.get("email") or uid
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
_BUILTIN_FK_RESOLVERS: Dict[str, Callable[[List[str]], Dict[str, str]]] = {
|
||||||
|
"Mandate": _resolveMandateLabels,
|
||||||
|
"FeatureInstance": _resolveInstanceLabels,
|
||||||
|
"User": _resolveUserLabels,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _buildLabelResolversFromModel(modelClass: type) -> Dict[str, Callable[[List[str]], Dict[str, str]]]:
|
||||||
|
"""
|
||||||
|
Auto-build labelResolvers dict from fk_model annotations on a Pydantic model.
|
||||||
|
Maps field names to resolver functions for all fields that have a known fk_model.
|
||||||
|
"""
|
||||||
|
from modules.connectors.connectorDbPostgre import _get_fk_sort_meta
|
||||||
|
fkMeta = _get_fk_sort_meta(modelClass)
|
||||||
|
resolvers: Dict[str, Callable[[List[str]], Dict[str, str]]] = {}
|
||||||
|
for fieldName, meta in fkMeta.items():
|
||||||
|
fkModelName = meta.get("model", "")
|
||||||
|
if fkModelName in _BUILTIN_FK_RESOLVERS:
|
||||||
|
resolvers[fieldName] = _BUILTIN_FK_RESOLVERS[fkModelName]
|
||||||
|
return resolvers
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cross-filter pagination parsing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def parseCrossFilterPagination(
|
||||||
|
column: str,
|
||||||
|
paginationJson: Optional[str],
|
||||||
|
) -> Optional[PaginationParams]:
|
||||||
|
"""
|
||||||
|
Parse pagination JSON, remove the requested column from filters (cross-filtering),
|
||||||
|
and drop sort — used for filter-values requests.
|
||||||
|
"""
|
||||||
|
if not paginationJson:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
paginationDict = json.loads(paginationJson)
|
||||||
|
if not paginationDict:
|
||||||
|
return None
|
||||||
|
paginationDict = normalize_pagination_dict(paginationDict)
|
||||||
|
filters = paginationDict.get("filters", {})
|
||||||
|
filters.pop(column, None)
|
||||||
|
paginationDict["filters"] = filters
|
||||||
|
paginationDict.pop("sort", None)
|
||||||
|
return PaginationParams(**paginationDict)
|
||||||
|
except (json.JSONDecodeError, ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parsePaginationForIds(
|
||||||
|
paginationJson: Optional[str],
|
||||||
|
) -> Optional[PaginationParams]:
|
||||||
|
"""
|
||||||
|
Parse pagination JSON for mode=ids — keep filters, drop sort and page/pageSize.
|
||||||
|
"""
|
||||||
|
if not paginationJson:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
paginationDict = json.loads(paginationJson)
|
||||||
|
if not paginationDict:
|
||||||
|
return None
|
||||||
|
paginationDict = normalize_pagination_dict(paginationDict)
|
||||||
|
paginationDict.pop("sort", None)
|
||||||
|
return PaginationParams(**paginationDict)
|
||||||
|
except (json.JSONDecodeError, ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SQL-based helpers (delegate to DB connector)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def handleFilterValuesMode(
|
||||||
|
db,
|
||||||
|
modelClass: type,
|
||||||
|
column: str,
|
||||||
|
paginationJson: Optional[str] = None,
|
||||||
|
recordFilter: Optional[Dict[str, Any]] = None,
|
||||||
|
enrichFn: Optional[Callable[[str, Optional[PaginationParams], Optional[Dict[str, Any]]], List[str]]] = None,
|
||||||
|
) -> List[str]:
|
||||||
|
"""
|
||||||
|
SQL-based distinct column values with cross-filtering.
|
||||||
|
|
||||||
|
If enrichFn is provided and the column is enriched (computed/joined),
|
||||||
|
enrichFn(column, crossPagination, recordFilter) is called instead of SQL DISTINCT.
|
||||||
|
"""
|
||||||
|
crossPagination = parseCrossFilterPagination(column, paginationJson)
|
||||||
|
|
||||||
|
if enrichFn:
|
||||||
|
try:
|
||||||
|
result = enrichFn(column, crossPagination, recordFilter)
|
||||||
|
if result is not None:
|
||||||
|
return JSONResponse(content=result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"handleFilterValuesMode enrichFn failed for {column}: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
values = db.getDistinctColumnValues(
|
||||||
|
modelClass, column,
|
||||||
|
pagination=crossPagination,
|
||||||
|
recordFilter=recordFilter,
|
||||||
|
) or []
|
||||||
|
return JSONResponse(content=values)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"handleFilterValuesMode SQL failed for {modelClass.__name__}.{column}: {e}")
|
||||||
|
return JSONResponse(content=[])
|
||||||
|
|
||||||
|
|
||||||
|
def handleIdsMode(
|
||||||
|
db,
|
||||||
|
modelClass: type,
|
||||||
|
paginationJson: Optional[str] = None,
|
||||||
|
recordFilter: Optional[Dict[str, Any]] = None,
|
||||||
|
idField: str = "id",
|
||||||
|
) -> List[str]:
|
||||||
|
"""
|
||||||
|
Return all IDs matching the current filters (no LIMIT/OFFSET).
|
||||||
|
Uses the same WHERE clause as getRecordsetPaginated.
|
||||||
|
"""
|
||||||
|
pagination = parsePaginationForIds(paginationJson)
|
||||||
|
table = modelClass.__name__
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not db._ensureTableExists(modelClass):
|
||||||
|
return JSONResponse(content=[])
|
||||||
|
|
||||||
|
where_clause, _, _, values, _ = db._buildPaginationClauses(
|
||||||
|
modelClass, pagination, recordFilter,
|
||||||
|
)
|
||||||
|
|
||||||
|
sql = f'SELECT "{idField}"::TEXT AS val FROM "{table}"{where_clause} ORDER BY "{idField}"'
|
||||||
|
|
||||||
|
with db.connection.cursor() as cursor:
|
||||||
|
cursor.execute(sql, values)
|
||||||
|
return JSONResponse(content=[row["val"] for row in cursor.fetchall()])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"handleIdsMode failed for {table}: {e}")
|
||||||
|
return JSONResponse(content=[])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# In-memory helpers (for enriched / non-SQL routes)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _applyFiltersAndSort(
|
||||||
|
items: List[Dict[str, Any]],
|
||||||
|
paginationParams: Optional[PaginationParams],
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Apply filters and sorting to a list of dicts in-memory.
|
||||||
|
Does NOT paginate (no page/pageSize slicing).
|
||||||
|
"""
|
||||||
|
if not paginationParams:
|
||||||
|
return items
|
||||||
|
|
||||||
|
result = list(items)
|
||||||
|
|
||||||
|
if paginationParams.filters:
|
||||||
|
filters = paginationParams.filters
|
||||||
|
searchTerm = filters.get("search", "").lower() if filters.get("search") else None
|
||||||
|
|
||||||
|
if searchTerm:
|
||||||
|
result = [
|
||||||
|
item for item in result
|
||||||
|
if any(
|
||||||
|
searchTerm in str(v).lower()
|
||||||
|
for v in item.values()
|
||||||
|
if v is not None
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
for field, filterValue in filters.items():
|
||||||
|
if field == "search":
|
||||||
|
continue
|
||||||
|
|
||||||
|
if isinstance(filterValue, dict) and "operator" in filterValue:
|
||||||
|
operator = filterValue.get("operator", "equals")
|
||||||
|
value = filterValue.get("value")
|
||||||
|
else:
|
||||||
|
operator = "equals"
|
||||||
|
value = filterValue
|
||||||
|
|
||||||
|
if value is None or value == "":
|
||||||
|
continue
|
||||||
|
|
||||||
|
result = [
|
||||||
|
item for item in result
|
||||||
|
if _matchesFilter(item, field, operator, value)
|
||||||
|
]
|
||||||
|
|
||||||
|
if paginationParams.sort:
|
||||||
|
for sortField in reversed(paginationParams.sort):
|
||||||
|
fieldName = sortField.field
|
||||||
|
ascending = sortField.direction == "asc"
|
||||||
|
|
||||||
|
noneItems = [item for item in result if item.get(fieldName) is None]
|
||||||
|
nonNoneItems = [item for item in result if item.get(fieldName) is not None]
|
||||||
|
|
||||||
|
def _getSortKey(item: Dict[str, Any], _fn=fieldName):
|
||||||
|
value = item.get(_fn)
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return (0, int(value), "")
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return (0, value, "")
|
||||||
|
return (1, 0, str(value).lower())
|
||||||
|
|
||||||
|
nonNoneItems = sorted(nonNoneItems, key=_getSortKey, reverse=not ascending)
|
||||||
|
result = nonNoneItems + noneItems
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _matchesFilter(item: Dict[str, Any], field: str, operator: str, value: Any) -> bool:
|
||||||
|
"""Single-field filter match for in-memory filtering."""
|
||||||
|
itemValue = item.get(field)
|
||||||
|
if itemValue is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
itemStr = str(itemValue).lower()
|
||||||
|
valueStr = str(value).lower()
|
||||||
|
|
||||||
|
if operator in ("equals", "eq"):
|
||||||
|
return itemStr == valueStr
|
||||||
|
if operator == "contains":
|
||||||
|
return valueStr in itemStr
|
||||||
|
if operator == "startsWith":
|
||||||
|
return itemStr.startswith(valueStr)
|
||||||
|
if operator == "endsWith":
|
||||||
|
return itemStr.endswith(valueStr)
|
||||||
|
if operator in ("gt", "gte", "lt", "lte"):
|
||||||
|
try:
|
||||||
|
itemNum = float(itemValue)
|
||||||
|
valueNum = float(value)
|
||||||
|
if operator == "gt":
|
||||||
|
return itemNum > valueNum
|
||||||
|
if operator == "gte":
|
||||||
|
return itemNum >= valueNum
|
||||||
|
if operator == "lt":
|
||||||
|
return itemNum < valueNum
|
||||||
|
return itemNum <= valueNum
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
if operator == "between":
|
||||||
|
return _matchesBetween(itemValue, itemStr, value)
|
||||||
|
if operator == "in":
|
||||||
|
if isinstance(value, list):
|
||||||
|
return itemStr in [str(x).lower() for x in value]
|
||||||
|
return False
|
||||||
|
if operator == "notIn":
|
||||||
|
if isinstance(value, list):
|
||||||
|
return itemStr not in [str(x).lower() for x in value]
|
||||||
|
return True
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _matchesBetween(itemValue: Any, itemStr: str, value: Any) -> bool:
|
||||||
|
"""Handle 'between' operator for date ranges and numeric ranges."""
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return True
|
||||||
|
fromVal = value.get("from", "")
|
||||||
|
toVal = value.get("to", "")
|
||||||
|
if not fromVal and not toVal:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
fromTs = None
|
||||||
|
toTs = None
|
||||||
|
if fromVal:
|
||||||
|
fromTs = datetime.strptime(str(fromVal), "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp()
|
||||||
|
if toVal:
|
||||||
|
toTs = datetime.strptime(str(toVal), "%Y-%m-%d").replace(
|
||||||
|
hour=23, minute=59, second=59, tzinfo=timezone.utc
|
||||||
|
).timestamp()
|
||||||
|
itemNum = float(itemValue) if not isinstance(itemValue, (int, float)) else itemValue
|
||||||
|
if itemNum > 10000000000:
|
||||||
|
itemNum = itemNum / 1000
|
||||||
|
if fromTs is not None and toTs is not None:
|
||||||
|
return fromTs <= itemNum <= toTs
|
||||||
|
if fromTs is not None:
|
||||||
|
return itemNum >= fromTs
|
||||||
|
if toTs is not None:
|
||||||
|
return itemNum <= toTs
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
fromStr = str(fromVal).lower() if fromVal else ""
|
||||||
|
toStr = str(toVal).lower() if toVal else ""
|
||||||
|
if fromStr and toStr:
|
||||||
|
return fromStr <= itemStr <= toStr
|
||||||
|
if fromStr:
|
||||||
|
return itemStr >= fromStr
|
||||||
|
if toStr:
|
||||||
|
return itemStr <= toStr
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _extractDistinctValues(
|
||||||
|
items: List[Dict[str, Any]],
|
||||||
|
columnKey: str,
|
||||||
|
requestLang: Optional[str] = None,
|
||||||
|
) -> List[str]:
|
||||||
|
"""Extract sorted distinct display values for a column from enriched items."""
|
||||||
|
values = set()
|
||||||
|
for item in items:
|
||||||
|
val = item.get(columnKey)
|
||||||
|
if val is None or val == "":
|
||||||
|
continue
|
||||||
|
if isinstance(val, bool):
|
||||||
|
values.add("true" if val else "false")
|
||||||
|
elif isinstance(val, (int, float)):
|
||||||
|
values.add(str(val))
|
||||||
|
elif isinstance(val, dict):
|
||||||
|
text = resolveText(val, requestLang)
|
||||||
|
if text:
|
||||||
|
values.add(text)
|
||||||
|
else:
|
||||||
|
values.add(str(val))
|
||||||
|
return sorted(values, key=lambda v: v.lower())
|
||||||
|
|
||||||
|
|
||||||
|
def handleFilterValuesInMemory(
|
||||||
|
items: List[Dict[str, Any]],
|
||||||
|
column: str,
|
||||||
|
paginationJson: Optional[str] = None,
|
||||||
|
requestLang: Optional[str] = None,
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""
|
||||||
|
In-memory filter-values: apply cross-filters, then extract distinct values.
|
||||||
|
For routes that build enriched in-memory lists.
|
||||||
|
Returns JSONResponse to bypass FastAPI response_model validation.
|
||||||
|
"""
|
||||||
|
crossFilterParams = parseCrossFilterPagination(column, paginationJson)
|
||||||
|
crossFiltered = _applyFiltersAndSort(items, crossFilterParams)
|
||||||
|
return JSONResponse(content=_extractDistinctValues(crossFiltered, column, requestLang))
|
||||||
|
|
||||||
|
|
||||||
|
def handleIdsInMemory(
|
||||||
|
items: List[Dict[str, Any]],
|
||||||
|
paginationJson: Optional[str] = None,
|
||||||
|
idField: str = "id",
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""
|
||||||
|
In-memory IDs: apply filters, return all IDs.
|
||||||
|
For routes that build enriched in-memory lists.
|
||||||
|
Returns JSONResponse to bypass FastAPI response_model validation.
|
||||||
|
"""
|
||||||
|
pagination = parsePaginationForIds(paginationJson)
|
||||||
|
filtered = _applyFiltersAndSort(items, pagination)
|
||||||
|
ids = []
|
||||||
|
for item in filtered:
|
||||||
|
val = item.get(idField)
|
||||||
|
if val is not None:
|
||||||
|
ids.append(str(val))
|
||||||
|
return JSONResponse(content=ids)
|
||||||
|
|
||||||
|
|
||||||
|
def getRecordsetPaginatedWithFkSort(
|
||||||
|
db,
|
||||||
|
modelClass: type,
|
||||||
|
pagination,
|
||||||
|
recordFilter: Optional[Dict[str, Any]] = None,
|
||||||
|
labelResolvers: Optional[Dict[str, Callable[[List[str]], Dict[str, str]]]] = None,
|
||||||
|
fieldFilter: Optional[List[str]] = None,
|
||||||
|
idField: str = "id",
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Wrapper around db.getRecordsetPaginated that handles FK-label sorting.
|
||||||
|
|
||||||
|
If the current sort field is a FK with a registered labelResolver, the
|
||||||
|
function fetches all filtered IDs + FK values, resolves labels cross-DB,
|
||||||
|
sorts in-memory by label, and returns only the requested page.
|
||||||
|
|
||||||
|
If no FK sort is active, delegates directly to db.getRecordsetPaginated.
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
|
||||||
|
if not pagination or not pagination.sort:
|
||||||
|
return db.getRecordsetPaginated(modelClass, pagination, recordFilter, fieldFilter)
|
||||||
|
|
||||||
|
if labelResolvers is None:
|
||||||
|
labelResolvers = _buildLabelResolversFromModel(modelClass)
|
||||||
|
|
||||||
|
if not labelResolvers:
|
||||||
|
return db.getRecordsetPaginated(modelClass, pagination, recordFilter, fieldFilter)
|
||||||
|
|
||||||
|
fkSortField = None
|
||||||
|
fkSortDir = "asc"
|
||||||
|
for sf in pagination.sort:
|
||||||
|
sfField = sf.get("field") if isinstance(sf, dict) else getattr(sf, "field", None)
|
||||||
|
sfDir = sf.get("direction", "asc") if isinstance(sf, dict) else getattr(sf, "direction", "asc")
|
||||||
|
if sfField and sfField in labelResolvers:
|
||||||
|
fkSortField = sfField
|
||||||
|
fkSortDir = str(sfDir).lower()
|
||||||
|
break
|
||||||
|
|
||||||
|
if not fkSortField:
|
||||||
|
return db.getRecordsetPaginated(modelClass, pagination, recordFilter, fieldFilter)
|
||||||
|
|
||||||
|
try:
|
||||||
|
distinctIds = db.getDistinctColumnValues(
|
||||||
|
modelClass, fkSortField, recordFilter=recordFilter,
|
||||||
|
) or []
|
||||||
|
|
||||||
|
labelMap = {}
|
||||||
|
if distinctIds:
|
||||||
|
try:
|
||||||
|
labelMap = labelResolvers[fkSortField](distinctIds)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"getRecordsetPaginatedWithFkSort: resolver for {fkSortField} failed: {e}")
|
||||||
|
|
||||||
|
filterOnlyPagination = copy.deepcopy(pagination)
|
||||||
|
filterOnlyPagination.sort = []
|
||||||
|
filterOnlyPagination.page = 1
|
||||||
|
filterOnlyPagination.pageSize = 999999
|
||||||
|
|
||||||
|
lightRows = db.getRecordsetPaginated(
|
||||||
|
modelClass, filterOnlyPagination, recordFilter,
|
||||||
|
fieldFilter=[idField, fkSortField],
|
||||||
|
)
|
||||||
|
allRows = lightRows.get("items", [])
|
||||||
|
totalItems = len(allRows)
|
||||||
|
|
||||||
|
if totalItems == 0:
|
||||||
|
return {"items": [], "totalItems": 0, "totalPages": 0}
|
||||||
|
|
||||||
|
def _sortKey(row):
|
||||||
|
fkVal = row.get(fkSortField, "") or ""
|
||||||
|
label = labelMap.get(str(fkVal), str(fkVal)).lower()
|
||||||
|
return label
|
||||||
|
|
||||||
|
reverse = fkSortDir == "desc"
|
||||||
|
allRows.sort(key=_sortKey, reverse=reverse)
|
||||||
|
|
||||||
|
pageSize = pagination.pageSize
|
||||||
|
offset = (pagination.page - 1) * pageSize
|
||||||
|
pageSlice = allRows[offset:offset + pageSize]
|
||||||
|
pageIds = [row[idField] for row in pageSlice if row.get(idField)]
|
||||||
|
|
||||||
|
if not pageIds:
|
||||||
|
return {"items": [], "totalItems": totalItems, "totalPages": math.ceil(totalItems / pageSize)}
|
||||||
|
|
||||||
|
pageItems = db.getRecordset(modelClass, recordFilter={idField: pageIds}, fieldFilter=fieldFilter)
|
||||||
|
|
||||||
|
idOrder = {pid: idx for idx, pid in enumerate(pageIds)}
|
||||||
|
pageItems.sort(key=lambda r: idOrder.get(r.get(idField), 999999))
|
||||||
|
|
||||||
|
totalPages = math.ceil(totalItems / pageSize) if totalItems > 0 else 0
|
||||||
|
return {"items": pageItems, "totalItems": totalItems, "totalPages": totalPages}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"getRecordsetPaginatedWithFkSort failed for {modelClass.__name__}: {e}")
|
||||||
|
return db.getRecordsetPaginated(modelClass, pagination, recordFilter, fieldFilter)
|
||||||
|
|
||||||
|
|
||||||
|
def paginateInMemory(
|
||||||
|
items: List[Dict[str, Any]],
|
||||||
|
paginationParams: Optional[PaginationParams],
|
||||||
|
) -> tuple:
|
||||||
|
"""
|
||||||
|
Apply pagination (page/pageSize slicing) to an already-filtered+sorted list.
|
||||||
|
Returns (pageItems, totalItems).
|
||||||
|
"""
|
||||||
|
totalItems = len(items)
|
||||||
|
if not paginationParams:
|
||||||
|
return items, totalItems
|
||||||
|
offset = (paginationParams.page - 1) * paginationParams.pageSize
|
||||||
|
pageItems = items[offset:offset + paginationParams.pageSize]
|
||||||
|
return pageItems, totalItems
|
||||||
|
|
@ -21,7 +21,7 @@ from pydantic import BaseModel, Field, model_validator
|
||||||
from modules.auth import limiter, getRequestContext, RequestContext, getCurrentUser
|
from modules.auth import limiter, getRequestContext, RequestContext, getCurrentUser
|
||||||
from modules.datamodels.datamodelUam import User
|
from modules.datamodels.datamodelUam import User
|
||||||
from modules.datamodels.datamodelPagination import PaginationParams, PaginationMetadata, normalize_pagination_dict
|
from modules.datamodels.datamodelPagination import PaginationParams, PaginationMetadata, normalize_pagination_dict
|
||||||
from modules.routes.routeDataUsers import _applyFiltersAndSort
|
from modules.routes.routeHelpers import _applyFiltersAndSort, handleFilterValuesInMemory, handleIdsInMemory
|
||||||
from modules.datamodels.datamodelInvitation import Invitation
|
from modules.datamodels.datamodelInvitation import Invitation
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
from modules.shared.timeUtils import getUtcTimestamp
|
from modules.shared.timeUtils import getUtcTimestamp
|
||||||
|
|
@ -408,6 +408,8 @@ def list_invitations(
|
||||||
includeUsed: bool = Query(False, description="Include already used invitations"),
|
includeUsed: bool = Query(False, description="Include already used invitations"),
|
||||||
includeExpired: bool = Query(False, description="Include expired invitations"),
|
includeExpired: bool = Query(False, description="Include expired invitations"),
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||||
|
mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"),
|
||||||
|
column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"),
|
||||||
context: RequestContext = Depends(getRequestContext)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
|
|
@ -439,41 +441,49 @@ def list_invitations(
|
||||||
detail=routeApiMsg("Mandate-Admin role required to list invitations")
|
detail=routeApiMsg("Mandate-Admin role required to list invitations")
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
def _buildInvitationItems():
|
||||||
rootInterface = getRootInterface()
|
rootInterface = getRootInterface()
|
||||||
|
|
||||||
# Get all invitations for this mandate (Pydantic models)
|
|
||||||
allInvitations = rootInterface.getInvitationsByMandate(str(context.mandateId))
|
allInvitations = rootInterface.getInvitationsByMandate(str(context.mandateId))
|
||||||
|
|
||||||
currentTime = getUtcTimestamp()
|
currentTime = getUtcTimestamp()
|
||||||
result = []
|
items = []
|
||||||
|
|
||||||
for inv in allInvitations:
|
for inv in allInvitations:
|
||||||
# Skip revoked invitations
|
|
||||||
if inv.revokedAt:
|
if inv.revokedAt:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Filter by usage
|
|
||||||
currentUses = inv.currentUses or 0
|
currentUses = inv.currentUses or 0
|
||||||
maxUses = inv.maxUses or 1
|
maxUses = inv.maxUses or 1
|
||||||
if not includeUsed and currentUses >= maxUses:
|
if not includeUsed and currentUses >= maxUses:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Filter by expiration
|
|
||||||
expiresAt = inv.expiresAt or 0
|
expiresAt = inv.expiresAt or 0
|
||||||
if not includeExpired and expiresAt < currentTime:
|
if not includeExpired and expiresAt < currentTime:
|
||||||
continue
|
continue
|
||||||
|
baseUrl = frontendUrl.rstrip("/") if frontendUrl else ""
|
||||||
# Build invite URL using frontend URL provided by the caller
|
inviteUrl = f"{baseUrl}/invite/{inv.token}" if baseUrl else ""
|
||||||
baseUrl = frontendUrl.rstrip("/")
|
items.append({
|
||||||
inviteUrl = f"{baseUrl}/invite/{inv.token}"
|
|
||||||
|
|
||||||
result.append({
|
|
||||||
**inv.model_dump(),
|
**inv.model_dump(),
|
||||||
"inviteUrl": inviteUrl,
|
"inviteUrl": inviteUrl,
|
||||||
"isExpired": expiresAt < currentTime,
|
"isExpired": expiresAt < currentTime,
|
||||||
"isUsedUp": currentUses >= maxUses
|
"isUsedUp": currentUses >= maxUses
|
||||||
})
|
})
|
||||||
|
return items
|
||||||
|
|
||||||
|
if mode == "filterValues":
|
||||||
|
if not column:
|
||||||
|
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
||||||
|
try:
|
||||||
|
return handleFilterValuesInMemory(_buildInvitationItems(), column, pagination)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting filter values for invitations: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
if mode == "ids":
|
||||||
|
try:
|
||||||
|
return handleIdsInMemory(_buildInvitationItems(), pagination)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting IDs for invitations: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = _buildInvitationItems()
|
||||||
|
|
||||||
paginationParams = None
|
paginationParams = None
|
||||||
if pagination:
|
if pagination:
|
||||||
|
|
@ -511,54 +521,6 @@ def list_invitations(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/filter-values")
|
|
||||||
@limiter.limit("60/minute")
|
|
||||||
def get_invitation_filter_values(
|
|
||||||
request: Request,
|
|
||||||
column: str = Query(..., description="Column key"),
|
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded current filters"),
|
|
||||||
frontendUrl: str = Query("", description="Frontend URL for building invite links"),
|
|
||||||
includeUsed: bool = Query(False, description="Include already used invitations"),
|
|
||||||
includeExpired: bool = Query(False, description="Include expired invitations"),
|
|
||||||
context: RequestContext = Depends(getRequestContext)
|
|
||||||
) -> list:
|
|
||||||
"""Return distinct filter values for a column in invitations."""
|
|
||||||
if not context.mandateId:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=routeApiMsg("X-Mandate-Id header is required"))
|
|
||||||
if not _hasMandateAdminRole(context):
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Mandate-Admin role required"))
|
|
||||||
try:
|
|
||||||
from modules.routes.routeDataUsers import _handleFilterValuesRequest
|
|
||||||
rootInterface = getRootInterface()
|
|
||||||
allInvitations = rootInterface.getInvitationsByMandate(str(context.mandateId))
|
|
||||||
currentTime = getUtcTimestamp()
|
|
||||||
result = []
|
|
||||||
for inv in allInvitations:
|
|
||||||
if inv.revokedAt:
|
|
||||||
continue
|
|
||||||
currentUses = inv.currentUses or 0
|
|
||||||
maxUses = inv.maxUses or 1
|
|
||||||
if not includeUsed and currentUses >= maxUses:
|
|
||||||
continue
|
|
||||||
expiresAt = inv.expiresAt or 0
|
|
||||||
if not includeExpired and expiresAt < currentTime:
|
|
||||||
continue
|
|
||||||
baseUrl = frontendUrl.rstrip("/") if frontendUrl else ""
|
|
||||||
inviteUrl = f"{baseUrl}/invite/{inv.token}" if baseUrl else ""
|
|
||||||
result.append({
|
|
||||||
**inv.model_dump(),
|
|
||||||
"inviteUrl": inviteUrl,
|
|
||||||
"isExpired": expiresAt < currentTime,
|
|
||||||
"isUsedUp": currentUses >= maxUses
|
|
||||||
})
|
|
||||||
return _handleFilterValuesRequest(result, column, pagination)
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting filter values for invitations: {e}")
|
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{invitationId}", response_model=Dict[str, str])
|
@router.delete("/{invitationId}", response_model=Dict[str, str])
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
def revoke_invitation(
|
def revoke_invitation(
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from modules.auth import limiter, getRequestContext, RequestContext
|
from modules.auth import limiter, getRequestContext, RequestContext
|
||||||
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
|
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
|
||||||
from modules.routes.routeDataUsers import _applyFiltersAndSort, _extractDistinctValues
|
from modules.routes.routeHelpers import _applyFiltersAndSort, handleFilterValuesInMemory, handleIdsInMemory
|
||||||
from modules.shared.i18nRegistry import apiRouteContext, resolveText
|
from modules.shared.i18nRegistry import apiRouteContext, resolveText
|
||||||
routeApiMsg = apiRouteContext("routeSubscription")
|
routeApiMsg = apiRouteContext("routeSubscription")
|
||||||
|
|
||||||
|
|
@ -397,7 +397,7 @@ def verifyCheckout(
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
def _buildEnrichedSubscriptions() -> List[Dict[str, Any]]:
|
def _buildEnrichedSubscriptions() -> List[Dict[str, Any]]:
|
||||||
"""Build the full enriched subscription list (shared by list + filter-values endpoints)."""
|
"""Build the full enriched subscription list (shared by list + mode=filterValues)."""
|
||||||
from modules.interfaces.interfaceDbSubscription import _getRootInterface as getSubRootInterface
|
from modules.interfaces.interfaceDbSubscription import _getRootInterface as getSubRootInterface
|
||||||
from modules.datamodels.datamodelSubscription import BUILTIN_PLANS, OPERATIVE_STATUSES
|
from modules.datamodels.datamodelSubscription import BUILTIN_PLANS, OPERATIVE_STATUSES
|
||||||
|
|
||||||
|
|
@ -480,12 +480,22 @@ def _buildEnrichedSubscriptions() -> List[Dict[str, Any]]:
|
||||||
def getAllSubscriptions(
|
def getAllSubscriptions(
|
||||||
request: Request,
|
request: Request,
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||||
|
mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"),
|
||||||
|
column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"),
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
"""SysAdmin: list ALL subscriptions across all mandates with enriched metadata."""
|
"""SysAdmin: list ALL subscriptions across all mandates with enriched metadata."""
|
||||||
if not context.hasSysAdminRole:
|
if not context.hasSysAdminRole:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Sysadmin role required"))
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Sysadmin role required"))
|
||||||
|
|
||||||
|
if mode == "filterValues":
|
||||||
|
if not column:
|
||||||
|
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
||||||
|
return handleFilterValuesInMemory(_buildEnrichedSubscriptions(), column, pagination)
|
||||||
|
|
||||||
|
if mode == "ids":
|
||||||
|
return handleIdsInMemory(_buildEnrichedSubscriptions(), pagination)
|
||||||
|
|
||||||
paginationParams: Optional[PaginationParams] = None
|
paginationParams: Optional[PaginationParams] = None
|
||||||
if pagination:
|
if pagination:
|
||||||
try:
|
try:
|
||||||
|
|
@ -520,38 +530,6 @@ def getAllSubscriptions(
|
||||||
return {"items": enriched, "pagination": None}
|
return {"items": enriched, "pagination": None}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/admin/all/filter-values")
|
|
||||||
@limiter.limit("60/minute")
|
|
||||||
def getFilterValues(
|
|
||||||
request: Request,
|
|
||||||
column: str = Query(..., description="Column key to extract distinct values for"),
|
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded current filters (applied except for the requested column)"),
|
|
||||||
context: RequestContext = Depends(getRequestContext),
|
|
||||||
):
|
|
||||||
"""Return distinct values for a column, respecting all active filters except the requested one."""
|
|
||||||
if not context.hasSysAdminRole:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Sysadmin role required"))
|
|
||||||
|
|
||||||
crossFilterParams: Optional[PaginationParams] = None
|
|
||||||
if pagination:
|
|
||||||
try:
|
|
||||||
paginationDict = json.loads(pagination)
|
|
||||||
if paginationDict:
|
|
||||||
paginationDict = normalize_pagination_dict(paginationDict)
|
|
||||||
filters = paginationDict.get("filters", {})
|
|
||||||
filters.pop(column, None)
|
|
||||||
paginationDict["filters"] = filters
|
|
||||||
paginationDict.pop("sort", None)
|
|
||||||
crossFilterParams = PaginationParams(**paginationDict)
|
|
||||||
except (json.JSONDecodeError, ValueError) as e:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}")
|
|
||||||
|
|
||||||
enriched = _buildEnrichedSubscriptions()
|
|
||||||
crossFiltered = _applyFiltersAndSort(enriched, crossFilterParams)
|
|
||||||
|
|
||||||
return _extractDistinctValues(crossFiltered, column)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Data Volume Usage per Mandate
|
# Data Volume Usage per Mandate
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ from slowapi.util import get_remote_address
|
||||||
|
|
||||||
from modules.auth.authentication import getRequestContext, RequestContext
|
from modules.auth.authentication import getRequestContext, RequestContext
|
||||||
from modules.system.mainSystem import NAVIGATION_SECTIONS, _objectKeyToUiComponent
|
from modules.system.mainSystem import NAVIGATION_SECTIONS, _objectKeyToUiComponent
|
||||||
from modules.shared.i18nRegistry import resolveText
|
from modules.shared.i18nRegistry import resolveText, t
|
||||||
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.datamodels.datamodelRbac import AccessRule, AccessRuleContext
|
from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext
|
||||||
|
|
@ -643,7 +643,7 @@ def _buildIntegrationsOverviewPayload(userId: str, user=None) -> Dict[str, Any]:
|
||||||
|
|
||||||
# --- Platform infra tools (only routes that exist in this deployment) ---
|
# --- Platform infra tools (only routes that exist in this deployment) ---
|
||||||
out["infraTools"] = [
|
out["infraTools"] = [
|
||||||
{"id": "voice", "label": "Voice / STT"},
|
{"id": "voice", "label": t("Voice / STT")},
|
||||||
]
|
]
|
||||||
|
|
||||||
accessible_instance_ids: Set[str] = set()
|
accessible_instance_ids: Set[str] = set()
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||||
from modules.shared.configuration import APP_CONFIG
|
from modules.shared.configuration import APP_CONFIG
|
||||||
from modules.datamodels.datamodelPagination import PaginationParams, normalize_pagination_dict
|
from modules.datamodels.datamodelPagination import PaginationParams, normalize_pagination_dict
|
||||||
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import (
|
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import (
|
||||||
AutoRun, AutoStepLog, AutoWorkflow, AutoTask,
|
AutoRun, AutoStepLog, AutoWorkflow, AutoTask, AutoVersion,
|
||||||
)
|
)
|
||||||
from modules.shared.i18nRegistry import apiRouteContext
|
from modules.shared.i18nRegistry import apiRouteContext
|
||||||
|
|
||||||
|
|
@ -143,6 +143,40 @@ def _scopedWorkflowFilter(context: RequestContext) -> Optional[dict]:
|
||||||
return {"mandateId": "__impossible__"}
|
return {"mandateId": "__impossible__"}
|
||||||
|
|
||||||
|
|
||||||
|
def _userMayDeleteWorkflow(context: RequestContext, wfMandateId: Optional[str]) -> bool:
|
||||||
|
"""Same rules as canDelete on rows in get_system_workflows."""
|
||||||
|
if context.hasSysAdminRole:
|
||||||
|
return True
|
||||||
|
userId = str(context.user.id) if context.user else None
|
||||||
|
if not userId or not wfMandateId:
|
||||||
|
return False
|
||||||
|
userMandateIds = _getUserMandateIds(userId)
|
||||||
|
adminMandateIds = _getAdminMandateIds(userId, userMandateIds)
|
||||||
|
return wfMandateId in adminMandateIds
|
||||||
|
|
||||||
|
|
||||||
|
def _cascadeDeleteAutoWorkflow(db: DatabaseConnector, workflowId: str) -> None:
|
||||||
|
"""Delete AutoWorkflow and dependent rows (same order as interfaceDbApp._cascadeDeleteGraphicalEditorData)."""
|
||||||
|
wf_id = workflowId
|
||||||
|
for v in db.getRecordset(AutoVersion, recordFilter={"workflowId": wf_id}) or []:
|
||||||
|
vid = v.get("id")
|
||||||
|
if vid:
|
||||||
|
db.recordDelete(AutoVersion, vid)
|
||||||
|
for run in db.getRecordset(AutoRun, recordFilter={"workflowId": wf_id}) or []:
|
||||||
|
run_id = run.get("id")
|
||||||
|
if not run_id:
|
||||||
|
continue
|
||||||
|
for sl in db.getRecordset(AutoStepLog, recordFilter={"runId": run_id}) or []:
|
||||||
|
slid = sl.get("id")
|
||||||
|
if slid:
|
||||||
|
db.recordDelete(AutoStepLog, slid)
|
||||||
|
db.recordDelete(AutoRun, run_id)
|
||||||
|
for task in db.getRecordset(AutoTask, recordFilter={"workflowId": wf_id}) or []:
|
||||||
|
tid = task.get("id")
|
||||||
|
if tid:
|
||||||
|
db.recordDelete(AutoTask, tid)
|
||||||
|
db.recordDelete(AutoWorkflow, wf_id)
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
@limiter.limit("60/minute")
|
@limiter.limit("60/minute")
|
||||||
|
|
@ -153,13 +187,30 @@ def get_workflow_runs(
|
||||||
status: Optional[str] = Query(None, description="Filter by status"),
|
status: Optional[str] = Query(None, description="Filter by status"),
|
||||||
mandateId: Optional[str] = Query(None, description="Filter by mandate"),
|
mandateId: Optional[str] = Query(None, description="Filter by mandate"),
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"),
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"),
|
||||||
|
mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"),
|
||||||
|
column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"),
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""List workflow runs with RBAC scoping (SQL-paginated)."""
|
"""List workflow runs with RBAC scoping (SQL-paginated)."""
|
||||||
db = _getDb()
|
db = _getDb()
|
||||||
if not db._ensureTableExists(AutoRun):
|
if not db._ensureTableExists(AutoRun):
|
||||||
|
if mode in ("filterValues", "ids"):
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
return JSONResponse(content=[])
|
||||||
return {"runs": [], "total": 0, "limit": limit, "offset": offset}
|
return {"runs": [], "total": 0, "limit": limit, "offset": offset}
|
||||||
|
|
||||||
|
if mode == "filterValues":
|
||||||
|
if not column:
|
||||||
|
from fastapi import HTTPException as _H
|
||||||
|
raise _H(status_code=400, detail="column parameter required for mode=filterValues")
|
||||||
|
return _enrichedFilterValues(db, context, AutoRun, _scopedRunFilter, column)
|
||||||
|
|
||||||
|
if mode == "ids":
|
||||||
|
from modules.routes.routeHelpers import handleIdsMode
|
||||||
|
baseFilter = _scopedRunFilter(context)
|
||||||
|
recordFilter = dict(baseFilter) if baseFilter else {}
|
||||||
|
return handleIdsMode(db, AutoRun, pagination, recordFilter)
|
||||||
|
|
||||||
baseFilter = _scopedRunFilter(context)
|
baseFilter = _scopedRunFilter(context)
|
||||||
recordFilter = dict(baseFilter) if baseFilter else {}
|
recordFilter = dict(baseFilter) if baseFilter else {}
|
||||||
|
|
||||||
|
|
@ -186,8 +237,9 @@ def get_workflow_runs(
|
||||||
sort=[{"field": "sysCreatedAt", "direction": "desc"}],
|
sort=[{"field": "sysCreatedAt", "direction": "desc"}],
|
||||||
)
|
)
|
||||||
|
|
||||||
result = db.getRecordsetPaginated(
|
from modules.routes.routeHelpers import getRecordsetPaginatedWithFkSort
|
||||||
AutoRun,
|
result = getRecordsetPaginatedWithFkSort(
|
||||||
|
db, AutoRun,
|
||||||
pagination=paginationParams,
|
pagination=paginationParams,
|
||||||
recordFilter=recordFilter if recordFilter else None,
|
recordFilter=recordFilter if recordFilter else None,
|
||||||
)
|
)
|
||||||
|
|
@ -340,13 +392,31 @@ def get_system_workflows(
|
||||||
active: Optional[bool] = Query(None, description="Filter by active status"),
|
active: Optional[bool] = Query(None, description="Filter by active status"),
|
||||||
mandateId: Optional[str] = Query(None, description="Filter by mandate"),
|
mandateId: Optional[str] = Query(None, description="Filter by mandate"),
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"),
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"),
|
||||||
|
mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"),
|
||||||
|
column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"),
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""List all workflows the user has access to (RBAC-scoped, cross-instance)."""
|
"""List all workflows the user has access to (RBAC-scoped, cross-instance)."""
|
||||||
db = _getDb()
|
db = _getDb()
|
||||||
if not db._ensureTableExists(AutoWorkflow):
|
if not db._ensureTableExists(AutoWorkflow):
|
||||||
|
if mode in ("filterValues", "ids"):
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
return JSONResponse(content=[])
|
||||||
return {"items": [], "pagination": {"currentPage": 1, "pageSize": 25, "totalItems": 0, "totalPages": 0}}
|
return {"items": [], "pagination": {"currentPage": 1, "pageSize": 25, "totalItems": 0, "totalPages": 0}}
|
||||||
|
|
||||||
|
if mode == "filterValues":
|
||||||
|
if not column:
|
||||||
|
from fastapi import HTTPException as _H
|
||||||
|
raise _H(status_code=400, detail="column parameter required for mode=filterValues")
|
||||||
|
return _enrichedFilterValues(db, context, AutoWorkflow, _scopedWorkflowFilter, column)
|
||||||
|
|
||||||
|
if mode == "ids":
|
||||||
|
from modules.routes.routeHelpers import handleIdsMode
|
||||||
|
baseFilter = _scopedWorkflowFilter(context)
|
||||||
|
recordFilter = dict(baseFilter) if baseFilter else {}
|
||||||
|
recordFilter["isTemplate"] = False
|
||||||
|
return handleIdsMode(db, AutoWorkflow, pagination, recordFilter)
|
||||||
|
|
||||||
baseFilter = _scopedWorkflowFilter(context)
|
baseFilter = _scopedWorkflowFilter(context)
|
||||||
recordFilter = dict(baseFilter) if baseFilter else {}
|
recordFilter = dict(baseFilter) if baseFilter else {}
|
||||||
recordFilter["isTemplate"] = False
|
recordFilter["isTemplate"] = False
|
||||||
|
|
@ -373,8 +443,9 @@ def get_system_workflows(
|
||||||
sort=[{"field": "sysCreatedAt", "direction": "desc"}],
|
sort=[{"field": "sysCreatedAt", "direction": "desc"}],
|
||||||
)
|
)
|
||||||
|
|
||||||
result = db.getRecordsetPaginated(
|
from modules.routes.routeHelpers import getRecordsetPaginatedWithFkSort
|
||||||
AutoWorkflow,
|
result = getRecordsetPaginatedWithFkSort(
|
||||||
|
db, AutoWorkflow,
|
||||||
pagination=paginationParams,
|
pagination=paginationParams,
|
||||||
recordFilter=recordFilter if recordFilter else None,
|
recordFilter=recordFilter if recordFilter else None,
|
||||||
)
|
)
|
||||||
|
|
@ -387,6 +458,7 @@ def get_system_workflows(
|
||||||
|
|
||||||
mandateLabelMap: dict = {}
|
mandateLabelMap: dict = {}
|
||||||
instanceLabelMap: dict = {}
|
instanceLabelMap: dict = {}
|
||||||
|
featureCodeMap: dict = {}
|
||||||
try:
|
try:
|
||||||
rootIface = getRootInterface()
|
rootIface = getRootInterface()
|
||||||
if mandateIds:
|
if mandateIds:
|
||||||
|
|
@ -400,6 +472,7 @@ def get_system_workflows(
|
||||||
fi = featureIface.getFeatureInstance(iid)
|
fi = featureIface.getFeatureInstance(iid)
|
||||||
if fi:
|
if fi:
|
||||||
instanceLabelMap[iid] = fi.label or iid
|
instanceLabelMap[iid] = fi.label or iid
|
||||||
|
featureCodeMap[iid] = fi.featureCode
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to enrich workflow labels: {e}")
|
logger.warning(f"Failed to enrich workflow labels: {e}")
|
||||||
|
|
||||||
|
|
@ -436,6 +509,7 @@ def get_system_workflows(
|
||||||
wfId = row.get("id")
|
wfId = row.get("id")
|
||||||
row["mandateLabel"] = mandateLabelMap.get(wMandateId, wMandateId or "—")
|
row["mandateLabel"] = mandateLabelMap.get(wMandateId, wMandateId or "—")
|
||||||
row["instanceLabel"] = instanceLabelMap.get(row.get("featureInstanceId"), row.get("featureInstanceId") or "—")
|
row["instanceLabel"] = instanceLabelMap.get(row.get("featureInstanceId"), row.get("featureInstanceId") or "—")
|
||||||
|
row["featureCode"] = featureCodeMap.get(row.get("featureInstanceId"), "")
|
||||||
row["isRunning"] = wfId in activeRunMap
|
row["isRunning"] = wfId in activeRunMap
|
||||||
row["activeRunId"] = activeRunMap.get(wfId)
|
row["activeRunId"] = activeRunMap.get(wfId)
|
||||||
row["runCount"] = runCountMap.get(wfId, 0)
|
row["runCount"] = runCountMap.get(wfId, 0)
|
||||||
|
|
@ -469,34 +543,67 @@ def get_system_workflows(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/workflows/{workflowId}")
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
def delete_system_workflow(
|
||||||
|
request: Request,
|
||||||
|
workflowId: str = Path(..., description="AutoWorkflow ID"),
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Delete a workflow by ID without requiring featureInstanceId (orphan / broken FK rows).
|
||||||
|
RBAC matches get_system_workflows: SysAdmin or Mandate-Admin for the workflow's mandate.
|
||||||
|
Cascades versions, runs, step logs, tasks — same as mandate cascade delete.
|
||||||
|
"""
|
||||||
|
db = _getDb()
|
||||||
|
if not db._ensureTableExists(AutoWorkflow):
|
||||||
|
raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found"))
|
||||||
|
|
||||||
|
rows = db.getRecordset(AutoWorkflow, recordFilter={"id": workflowId})
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found"))
|
||||||
|
|
||||||
|
wf = dict(rows[0]) if rows else {}
|
||||||
|
if wf.get("isTemplate"):
|
||||||
|
raise HTTPException(status_code=400, detail=routeApiMsg("Cannot delete a template workflow here"))
|
||||||
|
|
||||||
|
wf_mandate_id = wf.get("mandateId")
|
||||||
|
if not _userMayDeleteWorkflow(context, wf_mandate_id):
|
||||||
|
raise HTTPException(status_code=403, detail=routeApiMsg("Not allowed to delete this workflow"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
_cascadeDeleteAutoWorkflow(db, workflowId)
|
||||||
|
try:
|
||||||
|
from modules.shared.callbackRegistry import callbackRegistry
|
||||||
|
callbackRegistry.trigger("graphicalEditor.workflow.changed")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"delete_system_workflow cascade failed: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=routeApiMsg(str(e)))
|
||||||
|
|
||||||
|
return {"success": True, "id": workflowId}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Filter-values endpoints (for FormGeneratorTable column filters)
|
# Filter-values endpoints (for FormGeneratorTable column filters)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _enrichedFilterValues(
|
def _enrichedFilterValues(
|
||||||
db, context: RequestContext, modelClass, scopeFilter, column: str,
|
db, context: RequestContext, modelClass, scopeFilter, column: str,
|
||||||
) -> List[str]:
|
):
|
||||||
"""Return distinct filter values for enriched columns (mandateLabel, instanceLabel)
|
"""Return distinct filter values (IDs) for FK columns or delegate to DB-level DISTINCT.
|
||||||
or delegate to DB-level DISTINCT for raw columns."""
|
FK columns return raw IDs — the frontend resolves them to labels via fkCache.
|
||||||
|
Returns JSONResponse to bypass FastAPI response_model validation."""
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
if column in ("mandateLabel", "mandateId"):
|
if column in ("mandateLabel", "mandateId"):
|
||||||
baseFilter = scopeFilter(context)
|
baseFilter = scopeFilter(context)
|
||||||
recordFilter = dict(baseFilter) if baseFilter else {}
|
recordFilter = dict(baseFilter) if baseFilter else {}
|
||||||
if modelClass == AutoWorkflow:
|
if modelClass == AutoWorkflow:
|
||||||
recordFilter["isTemplate"] = False
|
recordFilter["isTemplate"] = False
|
||||||
items = db.getRecordset(modelClass, recordFilter=recordFilter or None, fieldFilter=["mandateId"]) or []
|
items = db.getRecordset(modelClass, recordFilter=recordFilter or None, fieldFilter=["mandateId"]) or []
|
||||||
mandateIds = list({r.get("mandateId") for r in items if r.get("mandateId")})
|
mandateIds = sorted({r.get("mandateId") for r in items if r.get("mandateId")})
|
||||||
if not mandateIds:
|
return JSONResponse(content=mandateIds)
|
||||||
return []
|
|
||||||
try:
|
|
||||||
rootIface = getRootInterface()
|
|
||||||
mMap = rootIface.getMandatesByIds(mandateIds)
|
|
||||||
labels = sorted({
|
|
||||||
getattr(m, "label", None) or getattr(m, "name", mid) or mid
|
|
||||||
for mid, m in mMap.items()
|
|
||||||
}, key=lambda v: v.lower())
|
|
||||||
return labels
|
|
||||||
except Exception:
|
|
||||||
return sorted(mandateIds)
|
|
||||||
|
|
||||||
if column in ("instanceLabel", "featureInstanceId"):
|
if column in ("instanceLabel", "featureInstanceId"):
|
||||||
baseFilter = scopeFilter(context)
|
baseFilter = scopeFilter(context)
|
||||||
|
|
@ -504,28 +611,15 @@ def _enrichedFilterValues(
|
||||||
if modelClass == AutoWorkflow:
|
if modelClass == AutoWorkflow:
|
||||||
recordFilter["isTemplate"] = False
|
recordFilter["isTemplate"] = False
|
||||||
items = db.getRecordset(modelClass, recordFilter=recordFilter or None, fieldFilter=["featureInstanceId"]) or []
|
items = db.getRecordset(modelClass, recordFilter=recordFilter or None, fieldFilter=["featureInstanceId"]) or []
|
||||||
instanceIds = list({r.get("featureInstanceId") for r in items if r.get("featureInstanceId")})
|
instanceIds = sorted({r.get("featureInstanceId") for r in items if r.get("featureInstanceId")})
|
||||||
else:
|
else:
|
||||||
items = db.getRecordset(modelClass, recordFilter=recordFilter or None, fieldFilter=["workflowId"]) or []
|
items = db.getRecordset(modelClass, recordFilter=recordFilter or None, fieldFilter=["workflowId"]) or []
|
||||||
wfIds = list({r.get("workflowId") for r in items if r.get("workflowId")})
|
wfIds = list({r.get("workflowId") for r in items if r.get("workflowId")})
|
||||||
instanceIds = []
|
instanceIds = []
|
||||||
if wfIds and db._ensureTableExists(AutoWorkflow):
|
if wfIds and db._ensureTableExists(AutoWorkflow):
|
||||||
wfs = db.getRecordset(AutoWorkflow, recordFilter={"id": wfIds}, fieldFilter=["featureInstanceId"]) or []
|
wfs = db.getRecordset(AutoWorkflow, recordFilter={"id": wfIds}, fieldFilter=["featureInstanceId"]) or []
|
||||||
instanceIds = list({w.get("featureInstanceId") for w in wfs if w.get("featureInstanceId")})
|
instanceIds = sorted({w.get("featureInstanceId") for w in wfs if w.get("featureInstanceId")})
|
||||||
if not instanceIds:
|
return JSONResponse(content=instanceIds)
|
||||||
return []
|
|
||||||
try:
|
|
||||||
from modules.interfaces.interfaceFeatures import getFeatureInterface
|
|
||||||
rootIface = getRootInterface()
|
|
||||||
featureIface = getFeatureInterface(rootIface.db)
|
|
||||||
labels = []
|
|
||||||
for iid in instanceIds:
|
|
||||||
fi = featureIface.getFeatureInstance(iid)
|
|
||||||
if fi:
|
|
||||||
labels.append(fi.label or iid)
|
|
||||||
return sorted(set(labels), key=lambda v: v.lower())
|
|
||||||
except Exception:
|
|
||||||
return sorted(instanceIds)
|
|
||||||
|
|
||||||
if column == "workflowLabel":
|
if column == "workflowLabel":
|
||||||
baseFilter = scopeFilter(context)
|
baseFilter = scopeFilter(context)
|
||||||
|
|
@ -543,43 +637,17 @@ def _enrichedFilterValues(
|
||||||
for wf in wfs:
|
for wf in wfs:
|
||||||
if wf.get("label"):
|
if wf.get("label"):
|
||||||
labels.add(wf["label"])
|
labels.add(wf["label"])
|
||||||
return sorted(labels, key=lambda v: v.lower())
|
return JSONResponse(content=sorted(labels, key=lambda v: v.lower()))
|
||||||
|
|
||||||
baseFilter = scopeFilter(context)
|
baseFilter = scopeFilter(context)
|
||||||
recordFilter = dict(baseFilter) if baseFilter else {}
|
recordFilter = dict(baseFilter) if baseFilter else {}
|
||||||
if modelClass == AutoWorkflow:
|
if modelClass == AutoWorkflow:
|
||||||
recordFilter["isTemplate"] = False
|
recordFilter["isTemplate"] = False
|
||||||
return db.getDistinctColumnValues(modelClass, column, recordFilter=recordFilter or None) or []
|
return JSONResponse(content=db.getDistinctColumnValues(modelClass, column, recordFilter=recordFilter or None) or [])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/filter-values")
|
|
||||||
@limiter.limit("60/minute")
|
|
||||||
def get_run_filter_values(
|
|
||||||
request: Request,
|
|
||||||
column: str = Query(..., description="Column key"),
|
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded current filters"),
|
|
||||||
context: RequestContext = Depends(getRequestContext),
|
|
||||||
) -> list:
|
|
||||||
"""Return distinct filter values for a column in workflow runs."""
|
|
||||||
db = _getDb()
|
|
||||||
if not db._ensureTableExists(AutoRun):
|
|
||||||
return []
|
|
||||||
return _enrichedFilterValues(db, context, AutoRun, _scopedRunFilter, column)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/workflows/filter-values")
|
|
||||||
@limiter.limit("60/minute")
|
|
||||||
def get_workflow_filter_values(
|
|
||||||
request: Request,
|
|
||||||
column: str = Query(..., description="Column key"),
|
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded current filters"),
|
|
||||||
context: RequestContext = Depends(getRequestContext),
|
|
||||||
) -> list:
|
|
||||||
"""Return distinct filter values for a column in workflows."""
|
|
||||||
db = _getDb()
|
|
||||||
if not db._ensureTableExists(AutoWorkflow):
|
|
||||||
return []
|
|
||||||
return _enrichedFilterValues(db, context, AutoWorkflow, _scopedWorkflowFilter, column)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -335,6 +335,9 @@ def _buildSchemaContext(
|
||||||
"",
|
"",
|
||||||
"RULES:",
|
"RULES:",
|
||||||
"- Do NOT invent table or field names. Do NOT prefix fields with UUIDs or dots.",
|
"- Do NOT invent table or field names. Do NOT prefix fields with UUIDs or dots.",
|
||||||
"- Answer concisely. Cite row counts and key values.",
|
"- CRITICAL: Return data as compact JSON, NOT as markdown tables or prose.",
|
||||||
|
"- Do NOT reformat, rewrite, or narrate the tool results. Return the raw data directly.",
|
||||||
|
"- If the question asks for rows, return them as a JSON array. Do NOT generate a markdown table.",
|
||||||
|
"- Keep your answer SHORT. The caller is a machine, not a human.",
|
||||||
]
|
]
|
||||||
return "\n".join(parts)
|
return "\n".join(parts)
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,17 @@ class ServiceHub:
|
||||||
|
|
||||||
functionsOnly = attrName != "ai"
|
functionsOnly = attrName != "ai"
|
||||||
|
|
||||||
|
def _makeServiceResolver(hub):
|
||||||
|
def _resolver(depKey: str):
|
||||||
|
return getattr(hub, depKey)
|
||||||
|
return _resolver
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
sig = inspect.signature(serviceClass.__init__)
|
||||||
|
paramCount = len([p for p in sig.parameters if p != 'self'])
|
||||||
|
if paramCount >= 2:
|
||||||
|
serviceInstance = serviceClass(self, _makeServiceResolver(self))
|
||||||
|
else:
|
||||||
serviceInstance = serviceClass(self)
|
serviceInstance = serviceClass(self)
|
||||||
setattr(self, attrName, PublicService(serviceInstance, functionsOnly=functionsOnly))
|
setattr(self, attrName, PublicService(serviceInstance, functionsOnly=functionsOnly))
|
||||||
logger.debug(f"Loaded service: {attrName} from {modulePath}")
|
logger.debug(f"Loaded service: {attrName} from {modulePath}")
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ class AttributeDefinition(BaseModel):
|
||||||
placeholder: Optional[str] = None
|
placeholder: Optional[str] = None
|
||||||
fkSource: Optional[str] = None
|
fkSource: Optional[str] = None
|
||||||
fkDisplayField: Optional[str] = None
|
fkDisplayField: Optional[str] = None
|
||||||
|
fkModel: Optional[str] = None # DB table / Pydantic model name for server-side FK sort (JOIN)
|
||||||
|
|
||||||
|
|
||||||
def _getModelLabelEntry(modelName: str) -> Dict[str, Any]:
|
def _getModelLabelEntry(modelName: str) -> Dict[str, Any]:
|
||||||
|
|
@ -136,6 +137,7 @@ def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguag
|
||||||
frontend_visible = True # Default visible
|
frontend_visible = True # Default visible
|
||||||
frontend_fk_source = None # FK dropdown source (e.g., "/api/users/")
|
frontend_fk_source = None # FK dropdown source (e.g., "/api/users/")
|
||||||
frontend_fk_display_field = None # Which field of the FK target to display (e.g., "username", "name")
|
frontend_fk_display_field = None # Which field of the FK target to display (e.g., "username", "name")
|
||||||
|
fk_model = None # Same as fk_model in json_schema_extra — backend JOIN target table name
|
||||||
|
|
||||||
if field_info:
|
if field_info:
|
||||||
# Try direct attributes first (though these won't exist for custom kwargs)
|
# Try direct attributes first (though these won't exist for custom kwargs)
|
||||||
|
|
@ -192,6 +194,8 @@ def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguag
|
||||||
# Extract frontend_fk_display_field - which field of FK target to display
|
# Extract frontend_fk_display_field - which field of FK target to display
|
||||||
if "frontend_fk_display_field" in json_extra:
|
if "frontend_fk_display_field" in json_extra:
|
||||||
frontend_fk_display_field = json_extra.get("frontend_fk_display_field")
|
frontend_fk_display_field = json_extra.get("frontend_fk_display_field")
|
||||||
|
if "fk_model" in json_extra:
|
||||||
|
fk_model = json_extra.get("fk_model")
|
||||||
|
|
||||||
# Use frontend type if available, otherwise detect from Python type
|
# Use frontend type if available, otherwise detect from Python type
|
||||||
if frontend_type:
|
if frontend_type:
|
||||||
|
|
@ -267,6 +271,8 @@ def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguag
|
||||||
# Also add display field if specified (which field of FK target to show)
|
# Also add display field if specified (which field of FK target to show)
|
||||||
if frontend_fk_display_field:
|
if frontend_fk_display_field:
|
||||||
attr_def["fkDisplayField"] = frontend_fk_display_field
|
attr_def["fkDisplayField"] = frontend_fk_display_field
|
||||||
|
if fk_model:
|
||||||
|
attr_def["fkModel"] = fk_model
|
||||||
|
|
||||||
attributes.append(attr_def)
|
attributes.append(attr_def)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -169,9 +169,23 @@ def _getExecutor(
|
||||||
_stepMeta: Dict[str, Dict[str, str]] = {}
|
_stepMeta: Dict[str, Dict[str, str]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _stripBinaryValues(obj: Any, depth: int = 0) -> Any:
|
||||||
|
"""Recursively replace bytes values with None to keep data JSON-safe for DB storage."""
|
||||||
|
if depth > 12:
|
||||||
|
return obj
|
||||||
|
if isinstance(obj, bytes):
|
||||||
|
return None
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return {k: _stripBinaryValues(v, depth + 1) for k, v in obj.items()}
|
||||||
|
if isinstance(obj, (list, tuple)):
|
||||||
|
return [_stripBinaryValues(v, depth + 1) for v in obj]
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
def _serializableOutputs(nodeOutputs: Dict[str, Any]) -> Dict[str, Any]:
|
def _serializableOutputs(nodeOutputs: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Return a shallow copy of nodeOutputs without the circular _context reference."""
|
"""Return a JSON-safe copy of nodeOutputs: strip _context and binary data."""
|
||||||
return {k: v for k, v in nodeOutputs.items() if k != "_context"}
|
cleaned = {k: v for k, v in nodeOutputs.items() if k != "_context"}
|
||||||
|
return _stripBinaryValues(cleaned)
|
||||||
|
|
||||||
|
|
||||||
def _emitStepEvent(runId: str, stepData: Dict[str, Any]) -> None:
|
def _emitStepEvent(runId: str, stepData: Dict[str, Any]) -> None:
|
||||||
|
|
@ -204,7 +218,7 @@ def _createStepLog(iface, runId: str, nodeId: str, nodeType: str, status: str =
|
||||||
"nodeId": nodeId,
|
"nodeId": nodeId,
|
||||||
"nodeType": nodeType,
|
"nodeType": nodeType,
|
||||||
"status": status,
|
"status": status,
|
||||||
"inputSnapshot": inputSnapshot or {},
|
"inputSnapshot": _stripBinaryValues(inputSnapshot) if inputSnapshot else {},
|
||||||
"startedAt": startedAt,
|
"startedAt": startedAt,
|
||||||
})
|
})
|
||||||
_stepMeta[stepId] = {"runId": runId, "nodeId": nodeId, "nodeType": nodeType}
|
_stepMeta[stepId] = {"runId": runId, "nodeId": nodeId, "nodeType": nodeType}
|
||||||
|
|
@ -231,7 +245,7 @@ def _updateStepLog(iface, stepId: str, status: str, output: Dict = None, error:
|
||||||
"completedAt": completedAt,
|
"completedAt": completedAt,
|
||||||
}
|
}
|
||||||
if output is not None:
|
if output is not None:
|
||||||
updates["output"] = output
|
updates["output"] = _stripBinaryValues(output)
|
||||||
if error is not None:
|
if error is not None:
|
||||||
updates["error"] = error
|
updates["error"] = error
|
||||||
if durationMs is not None:
|
if durationMs is not None:
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
from modules.features.graphicalEditor.portTypes import (
|
from modules.features.graphicalEditor.portTypes import (
|
||||||
INPUT_EXTRACTORS,
|
INPUT_EXTRACTORS,
|
||||||
|
|
@ -262,9 +262,10 @@ class ActionNodeExecutor:
|
||||||
}
|
}
|
||||||
raise PauseForEmailWaitError(runId=runId, nodeId=nodeId, waitConfig=waitConfig)
|
raise PauseForEmailWaitError(runId=runId, nodeId=nodeId, waitConfig=waitConfig)
|
||||||
|
|
||||||
# 7. AI nodes: simpleMode by default
|
# 7. AI nodes: normalize legacy "prompt" -> "aiPrompt"
|
||||||
if nodeType == "ai.prompt" and "simpleMode" not in resolvedParams:
|
if nodeType == "ai.prompt":
|
||||||
resolvedParams["simpleMode"] = True
|
if "aiPrompt" not in resolvedParams and "prompt" in resolvedParams:
|
||||||
|
resolvedParams["aiPrompt"] = resolvedParams.pop("prompt")
|
||||||
|
|
||||||
# 8. Build context for email.draftEmail from subject + body
|
# 8. Build context for email.draftEmail from subject + body
|
||||||
if nodeType == "email.draftEmail":
|
if nodeType == "email.draftEmail":
|
||||||
|
|
@ -280,34 +281,8 @@ class ActionNodeExecutor:
|
||||||
resolvedParams.pop("subject", None)
|
resolvedParams.pop("subject", None)
|
||||||
resolvedParams.pop("body", None)
|
resolvedParams.pop("body", None)
|
||||||
|
|
||||||
# 9. file.create: build context from upstream
|
# 9. Execute action
|
||||||
if nodeType == "file.create" and "context" not in resolvedParams:
|
logger.info("ActionNodeExecutor node %s calling %s.%s with %d params", nodeId, methodName, actionName, len(resolvedParams))
|
||||||
if 0 in inputSources:
|
|
||||||
srcId, _ = inputSources[0]
|
|
||||||
upstream = context.get("nodeOutputs", {}).get(srcId)
|
|
||||||
if upstream and isinstance(upstream, dict):
|
|
||||||
data = _unwrapTransit(upstream)
|
|
||||||
ctx = ""
|
|
||||||
if isinstance(data, dict):
|
|
||||||
ctx = data.get("context") or data.get("response") or data.get("text") or ""
|
|
||||||
if ctx:
|
|
||||||
resolvedParams["context"] = ctx
|
|
||||||
|
|
||||||
# 10. Pass upstream documents as documentList if available
|
|
||||||
# Use truthiness check: empty values ([], "", None) from static graph params
|
|
||||||
# must not block automatic upstream population via wire connections.
|
|
||||||
if not resolvedParams.get("documentList") and 0 in inputSources:
|
|
||||||
srcId, _ = inputSources[0]
|
|
||||||
upstream = context.get("nodeOutputs", {}).get(srcId)
|
|
||||||
if upstream and isinstance(upstream, dict):
|
|
||||||
data = _unwrapTransit(upstream)
|
|
||||||
if isinstance(data, dict):
|
|
||||||
docs = data.get("documents") or data.get("documentList")
|
|
||||||
if docs:
|
|
||||||
resolvedParams["documentList"] = docs
|
|
||||||
|
|
||||||
# 11. Execute action
|
|
||||||
logger.info("ActionNodeExecutor node %s calling %s.%s", nodeId, methodName, actionName)
|
|
||||||
try:
|
try:
|
||||||
executor = ActionExecutor(self.services)
|
executor = ActionExecutor(self.services)
|
||||||
result = await executor.executeAction(methodName, actionName, resolvedParams)
|
result = await executor.executeAction(methodName, actionName, resolvedParams)
|
||||||
|
|
@ -315,24 +290,61 @@ class ActionNodeExecutor:
|
||||||
logger.exception("ActionNodeExecutor node %s FAILED: %s", nodeId, e)
|
logger.exception("ActionNodeExecutor node %s FAILED: %s", nodeId, e)
|
||||||
return _normalizeError(e, outputSchema)
|
return _normalizeError(e, outputSchema)
|
||||||
|
|
||||||
# 12. Build normalized output
|
# 10. Persist generated documents as files and build JSON-safe output
|
||||||
docsList = [d.model_dump() if hasattr(d, "model_dump") else d for d in (result.documents or [])]
|
docsList = []
|
||||||
|
for d in (result.documents or []):
|
||||||
|
dumped = d.model_dump() if hasattr(d, "model_dump") else dict(d) if isinstance(d, dict) else d
|
||||||
|
rawData = getattr(d, "documentData", None) if hasattr(d, "documentData") else (dumped.get("documentData") if isinstance(dumped, dict) else None)
|
||||||
|
if isinstance(dumped, dict) and isinstance(rawData, bytes) and len(rawData) > 0:
|
||||||
|
try:
|
||||||
|
from modules.interfaces.interfaceDbManagement import getInterface as _getMgmtInterface
|
||||||
|
from modules.security.rootAccess import getRootUser
|
||||||
|
_userId = context.get("userId")
|
||||||
|
_mandateId = context.get("mandateId")
|
||||||
|
_instanceId = context.get("instanceId")
|
||||||
|
_mgmt = _getMgmtInterface(getRootUser(), mandateId=_mandateId, featureInstanceId=_instanceId)
|
||||||
|
_docName = dumped.get("documentName") or f"workflow-result-{nodeId}.bin"
|
||||||
|
_mimeType = dumped.get("mimeType") or "application/octet-stream"
|
||||||
|
_fileItem = _mgmt.createFile(_docName, _mimeType, rawData)
|
||||||
|
_mgmt.createFileData(_fileItem.id, rawData)
|
||||||
|
dumped["fileId"] = _fileItem.id
|
||||||
|
dumped["id"] = _fileItem.id
|
||||||
|
dumped["fileName"] = _fileItem.fileName
|
||||||
|
logger.info("Persisted workflow document %s as file %s", _docName, _fileItem.id)
|
||||||
|
except Exception as _fe:
|
||||||
|
logger.warning("Could not persist workflow document: %s", _fe)
|
||||||
|
dumped["documentData"] = None
|
||||||
|
dumped["_hasBinaryData"] = True
|
||||||
|
docsList.append(dumped)
|
||||||
|
|
||||||
extractedContext = ""
|
extractedContext = ""
|
||||||
if result.documents:
|
if result.documents:
|
||||||
doc = result.documents[0]
|
doc = result.documents[0]
|
||||||
raw = getattr(doc, "documentData", None) if hasattr(doc, "documentData") else (doc.get("documentData") if isinstance(doc, dict) else None)
|
raw = getattr(doc, "documentData", None) if hasattr(doc, "documentData") else (doc.get("documentData") if isinstance(doc, dict) else None)
|
||||||
if raw:
|
if isinstance(raw, bytes):
|
||||||
extractedContext = raw.decode("utf-8", errors="replace").strip() if isinstance(raw, bytes) else str(raw).strip()
|
try:
|
||||||
|
extractedContext = raw.decode("utf-8").strip()
|
||||||
|
except (UnicodeDecodeError, ValueError):
|
||||||
|
extractedContext = ""
|
||||||
|
elif raw:
|
||||||
|
extractedContext = str(raw).strip()
|
||||||
|
|
||||||
promptText = str(resolvedParams.get("aiPrompt") or resolvedParams.get("prompt") or "").strip()
|
promptText = str(resolvedParams.get("aiPrompt") or resolvedParams.get("prompt") or "").strip()
|
||||||
|
|
||||||
|
resultData = getattr(result, "data", None)
|
||||||
|
if resultData and isinstance(resultData, dict):
|
||||||
|
dataField = resultData
|
||||||
|
elif hasattr(result, "model_dump"):
|
||||||
|
dataField = result.model_dump()
|
||||||
|
else:
|
||||||
|
dataField = {"success": result.success, "error": result.error}
|
||||||
|
|
||||||
out = {
|
out = {
|
||||||
"success": result.success,
|
"success": result.success,
|
||||||
"error": result.error,
|
"error": result.error,
|
||||||
"documents": docsList,
|
"documents": docsList,
|
||||||
"documentList": docsList,
|
"documentList": docsList,
|
||||||
"data": result.model_dump() if hasattr(result, "model_dump") else {"success": result.success, "error": result.error},
|
"data": dataField,
|
||||||
}
|
}
|
||||||
|
|
||||||
if nodeType.startswith("ai."):
|
if nodeType.startswith("ai."):
|
||||||
|
|
|
||||||
|
|
@ -188,19 +188,17 @@ async def process(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
|
|
||||||
# Simple mode: fast path without document generation pipeline
|
# Simple mode: fast path without document generation pipeline
|
||||||
if simpleMode:
|
if simpleMode:
|
||||||
# Update progress - calling AI (simple mode)
|
|
||||||
self.services.chat.progressLogUpdate(operationId, 0.6, "Calling AI (simple mode)")
|
self.services.chat.progressLogUpdate(operationId, 0.6, "Calling AI (simple mode)")
|
||||||
|
|
||||||
# Extract context from documents if provided
|
context_parts = []
|
||||||
context_text = ""
|
paramContext = parameters.get("context")
|
||||||
|
if paramContext and isinstance(paramContext, str) and paramContext.strip():
|
||||||
|
context_parts.append(paramContext.strip())
|
||||||
if documentList and len(documentList.references) > 0:
|
if documentList and len(documentList.references) > 0:
|
||||||
try:
|
try:
|
||||||
# Get documents from workflow
|
|
||||||
documents = self.services.chat.getChatDocumentsFromDocumentList(documentList)
|
documents = self.services.chat.getChatDocumentsFromDocumentList(documentList)
|
||||||
context_parts = []
|
|
||||||
for doc in documents:
|
for doc in documents:
|
||||||
if hasattr(doc, 'fileId') and doc.fileId:
|
if hasattr(doc, 'fileId') and doc.fileId:
|
||||||
# Get file data
|
|
||||||
fileData = self.services.interfaceDbComponent.getFileData(doc.fileId)
|
fileData = self.services.interfaceDbComponent.getFileData(doc.fileId)
|
||||||
if fileData:
|
if fileData:
|
||||||
if isinstance(fileData, bytes):
|
if isinstance(fileData, bytes):
|
||||||
|
|
@ -208,12 +206,10 @@ async def process(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
else:
|
else:
|
||||||
doc_text = str(fileData)
|
doc_text = str(fileData)
|
||||||
context_parts.append(doc_text)
|
context_parts.append(doc_text)
|
||||||
if context_parts:
|
|
||||||
context_text = "\n\n".join(context_parts)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Error extracting context from documents in simple mode: {e}")
|
logger.warning(f"Error extracting context from documents in simple mode: {e}")
|
||||||
|
context_text = "\n\n".join(context_parts) if context_parts else ""
|
||||||
|
|
||||||
# Use direct AI call without document generation pipeline
|
|
||||||
request = AiCallRequest(
|
request = AiCallRequest(
|
||||||
prompt=aiPrompt,
|
prompt=aiPrompt,
|
||||||
context=context_text if context_text else None,
|
context=context_text if context_text else None,
|
||||||
|
|
@ -260,7 +256,10 @@ async def process(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
# For code generation, use ai.generateCode action or explicitly pass generationIntent="code"
|
# For code generation, use ai.generateCode action or explicitly pass generationIntent="code"
|
||||||
generationIntent = parameters.get("generationIntent", "document")
|
generationIntent = parameters.get("generationIntent", "document")
|
||||||
|
|
||||||
# Update progress - calling AI
|
paramContext = parameters.get("context")
|
||||||
|
if paramContext and isinstance(paramContext, str) and paramContext.strip():
|
||||||
|
aiPrompt = f"{aiPrompt}\n\n--- DATA CONTEXT ---\n{paramContext.strip()}"
|
||||||
|
|
||||||
self.services.chat.progressLogUpdate(operationId, 0.6, "Calling AI")
|
self.services.chat.progressLogUpdate(operationId, 0.6, "Calling AI")
|
||||||
|
|
||||||
# Use unified callAiContent method with BOTH documentList and contentParts
|
# Use unified callAiContent method with BOTH documentList and contentParts
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ async def create(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
Create a file from context (text/markdown from upstream AI node).
|
Create a file from context (text/markdown from upstream AI node).
|
||||||
Uses GenerationService.renderReport to produce docx, pdf, txt, md, html, xlsx, etc.
|
Uses GenerationService.renderReport to produce docx, pdf, txt, md, html, xlsx, etc.
|
||||||
"""
|
"""
|
||||||
context = parameters.get("context", "") or ""
|
context = parameters.get("context", "") or parameters.get("text", "") or ""
|
||||||
if not isinstance(context, str):
|
if not isinstance(context, str):
|
||||||
context = str(context) if context else ""
|
context = str(context) if context else ""
|
||||||
context = context.strip()
|
context = context.strip()
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import logging
|
||||||
import time
|
import time
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
|
|
||||||
from modules.datamodels.datamodelChat import ActionResult, ActionDocument
|
from modules.datamodels.datamodelChat import ActionResult
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -75,13 +75,11 @@ async def refreshAccountingData(self, parameters: Dict[str, Any]) -> ActionResul
|
||||||
counts["lastSyncAt"] = lastSyncAt
|
counts["lastSyncAt"] = lastSyncAt
|
||||||
counts["lastSyncStatus"] = lastSyncStatus
|
counts["lastSyncStatus"] = lastSyncStatus
|
||||||
counts["message"] = f"Data is fresh (synced {int(time.time() - lastSyncAt)}s ago). Use forceRefresh=true to re-import."
|
counts["message"] = f"Data is fresh (synced {int(time.time() - lastSyncAt)}s ago). Use forceRefresh=true to re-import."
|
||||||
return ActionResult.isSuccess(documents=[
|
dataExport = _exportAccountingData(trusteeInterface, featureInstanceId, dateFrom, dateTo)
|
||||||
ActionDocument(
|
return ActionResult.isSuccess(data={
|
||||||
documentName="refresh_result",
|
"summary": counts,
|
||||||
documentData=json.dumps(counts, ensure_ascii=False),
|
"accountingData": dataExport,
|
||||||
mimeType="application/json",
|
})
|
||||||
)
|
|
||||||
])
|
|
||||||
|
|
||||||
from modules.features.trustee.accounting.accountingDataSync import AccountingDataSync
|
from modules.features.trustee.accounting.accountingDataSync import AccountingDataSync
|
||||||
|
|
||||||
|
|
@ -103,18 +101,108 @@ async def refreshAccountingData(self, parameters: Dict[str, Any]) -> ActionResul
|
||||||
except Exception as cacheErr:
|
except Exception as cacheErr:
|
||||||
logger.warning("Could not clear feature query cache: %s", cacheErr)
|
logger.warning("Could not clear feature query cache: %s", cacheErr)
|
||||||
|
|
||||||
return ActionResult.isSuccess(documents=[
|
dataExport = _exportAccountingData(trusteeInterface, featureInstanceId, dateFrom, dateTo)
|
||||||
ActionDocument(
|
return ActionResult.isSuccess(data={
|
||||||
documentName="refresh_result",
|
"summary": summary,
|
||||||
documentData=json.dumps(summary, ensure_ascii=False),
|
"accountingData": dataExport,
|
||||||
mimeType="application/json",
|
})
|
||||||
)
|
|
||||||
])
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("refreshAccountingData failed")
|
logger.exception("refreshAccountingData failed")
|
||||||
return ActionResult.isFailure(error=str(e))
|
return ActionResult.isFailure(error=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def _exportAccountingData(trusteeInterface, featureInstanceId: str, dateFrom: str = None, dateTo: str = None) -> str:
|
||||||
|
"""Export accounting data (accounts, balances, journal entries+lines) as compact JSON for downstream AI nodes."""
|
||||||
|
from modules.features.trustee.datamodelFeatureTrustee import (
|
||||||
|
TrusteeDataAccount,
|
||||||
|
TrusteeDataJournalEntry,
|
||||||
|
TrusteeDataJournalLine,
|
||||||
|
TrusteeDataAccountBalance,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
baseFilter = {"featureInstanceId": featureInstanceId}
|
||||||
|
|
||||||
|
accounts = trusteeInterface.db.getRecordset(TrusteeDataAccount, recordFilter=baseFilter) or []
|
||||||
|
accountMap = {}
|
||||||
|
for a in accounts:
|
||||||
|
nr = a.get("accountNumber", "")
|
||||||
|
accountMap[nr] = {
|
||||||
|
"nr": nr,
|
||||||
|
"label": a.get("label", ""),
|
||||||
|
"type": a.get("accountType", ""),
|
||||||
|
"group": a.get("accountGroup", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
balances = trusteeInterface.db.getRecordset(TrusteeDataAccountBalance, recordFilter=baseFilter) or []
|
||||||
|
balanceList = []
|
||||||
|
for b in balances:
|
||||||
|
balanceList.append({
|
||||||
|
"account": b.get("accountNumber", ""),
|
||||||
|
"year": b.get("periodYear", 0),
|
||||||
|
"month": b.get("periodMonth", 0),
|
||||||
|
"opening": b.get("openingBalance", 0),
|
||||||
|
"debit": b.get("debitTotal", 0),
|
||||||
|
"credit": b.get("creditTotal", 0),
|
||||||
|
"closing": b.get("closingBalance", 0),
|
||||||
|
})
|
||||||
|
|
||||||
|
entries = trusteeInterface.db.getRecordset(TrusteeDataJournalEntry, recordFilter=baseFilter) or []
|
||||||
|
entryMap = {}
|
||||||
|
for e in entries:
|
||||||
|
eid = e.get("id", "")
|
||||||
|
bDate = e.get("bookingDate", "")
|
||||||
|
if dateFrom and bDate and bDate < dateFrom:
|
||||||
|
continue
|
||||||
|
if dateTo and bDate and bDate > dateTo:
|
||||||
|
continue
|
||||||
|
entryMap[eid] = {
|
||||||
|
"date": bDate,
|
||||||
|
"ref": e.get("reference", ""),
|
||||||
|
"desc": e.get("description", ""),
|
||||||
|
"amount": e.get("totalAmount", 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
lines = trusteeInterface.db.getRecordset(TrusteeDataJournalLine, recordFilter=baseFilter) or []
|
||||||
|
lineList = []
|
||||||
|
for ln in lines:
|
||||||
|
jeId = ln.get("journalEntryId", "")
|
||||||
|
if jeId not in entryMap:
|
||||||
|
continue
|
||||||
|
entry = entryMap[jeId]
|
||||||
|
lineList.append({
|
||||||
|
"date": entry["date"],
|
||||||
|
"ref": entry["ref"],
|
||||||
|
"account": ln.get("accountNumber", ""),
|
||||||
|
"accountLabel": accountMap.get(ln.get("accountNumber", ""), {}).get("label", ""),
|
||||||
|
"debit": ln.get("debitAmount", 0),
|
||||||
|
"credit": ln.get("creditAmount", 0),
|
||||||
|
"desc": ln.get("description", "") or entry["desc"],
|
||||||
|
"taxCode": ln.get("taxCode", ""),
|
||||||
|
"costCenter": ln.get("costCenter", ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
export = {
|
||||||
|
"accounts": list(accountMap.values()),
|
||||||
|
"balances": balanceList,
|
||||||
|
"journalLines": lineList,
|
||||||
|
"meta": {
|
||||||
|
"accountCount": len(accountMap),
|
||||||
|
"entryCount": len(entryMap),
|
||||||
|
"lineCount": len(lineList),
|
||||||
|
"balanceCount": len(balanceList),
|
||||||
|
"dateFrom": dateFrom,
|
||||||
|
"dateTo": dateTo,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result = json.dumps(export, ensure_ascii=False, default=str)
|
||||||
|
logger.info("Exported accounting data: %d accounts, %d entries, %d lines, %d balances (%d bytes)",
|
||||||
|
len(accountMap), len(entryMap), len(lineList), len(balanceList), len(result))
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Could not export accounting data: %s", e)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _getCachedCounts(trusteeInterface, featureInstanceId: str) -> Dict[str, Any]:
|
def _getCachedCounts(trusteeInterface, featureInstanceId: str) -> Dict[str, Any]:
|
||||||
"""Count existing records per TrusteeData* table without triggering an external sync."""
|
"""Count existing records per TrusteeData* table without triggering an external sync."""
|
||||||
from modules.features.trustee.datamodelFeatureTrustee import (
|
from modules.features.trustee.datamodelFeatureTrustee import (
|
||||||
|
|
|
||||||
|
|
@ -196,20 +196,6 @@ except Exception as e:
|
||||||
errors.append(f"Phase 1 Registration: {e}")
|
errors.append(f"Phase 1 Registration: {e}")
|
||||||
print(f" [FAIL] 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 ────────────────────────────────────────
|
# ── Fix 1: OnboardingWizard Integration ────────────────────────────────────────
|
||||||
print("\n--- Fix 1: OnboardingWizard Integration ---")
|
print("\n--- Fix 1: OnboardingWizard Integration ---")
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue