streamlined formgeneratortable and sort/filter globally
This commit is contained in:
parent
c813bd63ca
commit
2e7a0a73c7
30 changed files with 2402 additions and 3749 deletions
|
|
@ -1,6 +1,7 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
import contextvars
|
||||
import re
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
import logging
|
||||
|
|
@ -92,19 +93,27 @@ def _get_model_fields(model_class) -> Dict[str, str]:
|
|||
fields[field_name] = extra["db_type"]
|
||||
continue
|
||||
|
||||
# Check for JSONB fields (Dict, List, or complex types)
|
||||
# Unwrap Optional[X] → X (handles both typing.Union and types.UnionType)
|
||||
origin = get_origin(field_type)
|
||||
if origin is Union:
|
||||
args = [a for a in get_args(field_type) if a is not type(None)]
|
||||
if len(args) == 1:
|
||||
field_type = args[0]
|
||||
elif hasattr(field_type, '__args__') and type(None) in getattr(field_type, '__args__', ()):
|
||||
args = [a for a in field_type.__args__ if a is not type(None)]
|
||||
if len(args) == 1:
|
||||
field_type = args[0]
|
||||
|
||||
if _isJsonbType(field_type):
|
||||
fields[field_name] = "JSONB"
|
||||
elif field_type in (str, type(None)) or (
|
||||
get_origin(field_type) is Union and type(None) in get_args(field_type)
|
||||
):
|
||||
fields[field_name] = "TEXT"
|
||||
elif field_type == int:
|
||||
fields[field_name] = "INTEGER"
|
||||
elif field_type == float:
|
||||
fields[field_name] = "DOUBLE PRECISION"
|
||||
elif field_type == bool:
|
||||
elif field_type is bool:
|
||||
fields[field_name] = "BOOLEAN"
|
||||
elif field_type is int:
|
||||
fields[field_name] = "INTEGER"
|
||||
elif field_type is float:
|
||||
fields[field_name] = "DOUBLE PRECISION"
|
||||
elif field_type in (str, type(None)):
|
||||
fields[field_name] = "TEXT"
|
||||
else:
|
||||
fields[field_name] = "TEXT"
|
||||
|
||||
|
|
@ -135,6 +144,9 @@ def _parseRecordFields(record: Dict[str, Any], fields: Dict[str, str], context:
|
|||
elif isinstance(value, list):
|
||||
pass # already a list
|
||||
|
||||
elif fieldType == "BOOLEAN":
|
||||
record[fieldName] = bool(value) if value is not None else False
|
||||
|
||||
elif fieldType == "JSONB" and value is not None:
|
||||
try:
|
||||
if isinstance(value, str):
|
||||
|
|
@ -969,6 +981,244 @@ class DatabaseConnector:
|
|||
logger.error(f"Error loading records from table {table}: {e}")
|
||||
return []
|
||||
|
||||
def _buildPaginationClauses(
|
||||
self,
|
||||
model_class: type,
|
||||
pagination,
|
||||
recordFilter: Dict[str, Any] = None,
|
||||
):
|
||||
"""
|
||||
Translate PaginationParams + recordFilter into SQL clauses.
|
||||
Returns (where_clause, order_clause, limit_clause, values, count_values).
|
||||
"""
|
||||
fields = _get_model_fields(model_class)
|
||||
validColumns = set(fields.keys())
|
||||
where_parts: List[str] = []
|
||||
values: List[Any] = []
|
||||
|
||||
if recordFilter:
|
||||
for field, value in recordFilter.items():
|
||||
if value is None:
|
||||
where_parts.append(f'"{field}" IS NULL')
|
||||
elif isinstance(value, list):
|
||||
where_parts.append(f'"{field}" = ANY(%s)')
|
||||
values.append(value)
|
||||
else:
|
||||
where_parts.append(f'"{field}" = %s')
|
||||
values.append(value)
|
||||
|
||||
if pagination and pagination.filters:
|
||||
for key, val in pagination.filters.items():
|
||||
if key == "search" and isinstance(val, str) and val.strip():
|
||||
term = f"%{val.strip()}%"
|
||||
textCols = [c for c, t in fields.items() if t == "TEXT"]
|
||||
if textCols:
|
||||
orParts = [f'COALESCE("{c}"::TEXT, \'\') ILIKE %s' for c in textCols]
|
||||
where_parts.append(f"({' OR '.join(orParts)})")
|
||||
values.extend([term] * len(textCols))
|
||||
continue
|
||||
if key not in validColumns:
|
||||
logger.debug(f"_buildPaginationClauses: key '{key}' NOT in validColumns {list(validColumns)[:10]}")
|
||||
continue
|
||||
colType = fields.get(key, "TEXT")
|
||||
logger.debug(f"_buildPaginationClauses: filter key='{key}' val={val!r} type(val)={type(val).__name__} colType={colType}")
|
||||
if isinstance(val, dict):
|
||||
op = val.get("operator", "equals")
|
||||
v = val.get("value", "")
|
||||
if op in ("equals", "eq"):
|
||||
if colType == "BOOLEAN":
|
||||
where_parts.append(f'COALESCE("{key}", FALSE) = %s')
|
||||
values.append(str(v).lower() == "true")
|
||||
else:
|
||||
where_parts.append(f'"{key}"::TEXT = %s')
|
||||
values.append(str(v))
|
||||
elif op == "contains":
|
||||
where_parts.append(f'"{key}"::TEXT ILIKE %s')
|
||||
values.append(f"%{v}%")
|
||||
elif op == "startsWith":
|
||||
where_parts.append(f'"{key}"::TEXT ILIKE %s')
|
||||
values.append(f"{v}%")
|
||||
elif op == "endsWith":
|
||||
where_parts.append(f'"{key}"::TEXT ILIKE %s')
|
||||
values.append(f"%{v}")
|
||||
elif op in ("gt", "gte", "lt", "lte"):
|
||||
sqlOp = {"gt": ">", "gte": ">=", "lt": "<", "lte": "<="}[op]
|
||||
where_parts.append(f'"{key}"::TEXT {sqlOp} %s')
|
||||
values.append(str(v))
|
||||
elif op == "between":
|
||||
fromVal = v.get("from", "") if isinstance(v, dict) else ""
|
||||
toVal = v.get("to", "") if isinstance(v, dict) else ""
|
||||
if not fromVal and not toVal:
|
||||
continue
|
||||
colType = fields.get(key, "TEXT")
|
||||
isNumericCol = colType in ("INTEGER", "DOUBLE PRECISION")
|
||||
isDateVal = bool(fromVal and re.match(r'^\d{4}-\d{2}-\d{2}$', str(fromVal))) or \
|
||||
bool(toVal and re.match(r'^\d{4}-\d{2}-\d{2}$', str(toVal)))
|
||||
if isNumericCol and isDateVal:
|
||||
from datetime import datetime as _dt, timezone as _tz
|
||||
if fromVal and toVal:
|
||||
fromTs = _dt.strptime(str(fromVal), '%Y-%m-%d').replace(tzinfo=_tz.utc).timestamp()
|
||||
toTs = _dt.strptime(str(toVal), '%Y-%m-%d').replace(hour=23, minute=59, second=59, tzinfo=_tz.utc).timestamp()
|
||||
where_parts.append(f'"{key}" >= %s AND "{key}" <= %s')
|
||||
values.extend([fromTs, toTs])
|
||||
elif fromVal:
|
||||
fromTs = _dt.strptime(str(fromVal), '%Y-%m-%d').replace(tzinfo=_tz.utc).timestamp()
|
||||
where_parts.append(f'"{key}" >= %s')
|
||||
values.append(fromTs)
|
||||
else:
|
||||
toTs = _dt.strptime(str(toVal), '%Y-%m-%d').replace(hour=23, minute=59, second=59, tzinfo=_tz.utc).timestamp()
|
||||
where_parts.append(f'"{key}" <= %s')
|
||||
values.append(toTs)
|
||||
else:
|
||||
if fromVal and toVal:
|
||||
where_parts.append(f'"{key}"::TEXT >= %s AND "{key}"::TEXT <= %s')
|
||||
values.extend([str(fromVal), str(toVal)])
|
||||
elif fromVal:
|
||||
where_parts.append(f'"{key}"::TEXT >= %s')
|
||||
values.append(str(fromVal))
|
||||
elif toVal:
|
||||
where_parts.append(f'"{key}"::TEXT <= %s')
|
||||
values.append(str(toVal))
|
||||
else:
|
||||
if colType == "BOOLEAN":
|
||||
where_parts.append(f'COALESCE("{key}", FALSE) = %s')
|
||||
values.append(str(val).lower() == "true")
|
||||
else:
|
||||
where_parts.append(f'"{key}"::TEXT ILIKE %s')
|
||||
values.append(str(val))
|
||||
|
||||
where_clause = " WHERE " + " AND ".join(where_parts) if where_parts else ""
|
||||
count_values = list(values)
|
||||
|
||||
orderParts: List[str] = []
|
||||
if pagination and pagination.sort:
|
||||
for sf in pagination.sort:
|
||||
if sf.field in validColumns:
|
||||
direction = "DESC" if sf.direction.lower() == "desc" else "ASC"
|
||||
colType = fields.get(sf.field, "TEXT")
|
||||
if colType == "BOOLEAN":
|
||||
orderParts.append(f'COALESCE("{sf.field}", FALSE) {direction}')
|
||||
else:
|
||||
orderParts.append(f'"{sf.field}" {direction} NULLS LAST')
|
||||
if not orderParts:
|
||||
orderParts.append('"id"')
|
||||
order_clause = " ORDER BY " + ", ".join(orderParts)
|
||||
|
||||
limit_clause = ""
|
||||
if pagination:
|
||||
offset = (pagination.page - 1) * pagination.pageSize
|
||||
limit_clause = f" LIMIT {pagination.pageSize} OFFSET {offset}"
|
||||
|
||||
return where_clause, order_clause, limit_clause, values, count_values
|
||||
|
||||
def getRecordsetPaginated(
|
||||
self,
|
||||
model_class: type,
|
||||
pagination=None,
|
||||
recordFilter: Dict[str, Any] = None,
|
||||
fieldFilter: List[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Returns paginated records with filtering + sorting at the SQL level.
|
||||
Returns { "items": [...], "totalItems": int, "totalPages": int }.
|
||||
If pagination is None, returns all records (no LIMIT/OFFSET).
|
||||
"""
|
||||
from modules.datamodels.datamodelPagination import PaginationParams
|
||||
import math
|
||||
|
||||
table = model_class.__name__
|
||||
|
||||
try:
|
||||
if not self._ensureTableExists(model_class):
|
||||
return {"items": [], "totalItems": 0, "totalPages": 0}
|
||||
|
||||
where_clause, order_clause, limit_clause, values, count_values = \
|
||||
self._buildPaginationClauses(model_class, pagination, recordFilter)
|
||||
|
||||
with self.connection.cursor() as cursor:
|
||||
countSql = f'SELECT COUNT(*) FROM "{table}"{where_clause}'
|
||||
cursor.execute(countSql, count_values)
|
||||
totalItems = cursor.fetchone()["count"]
|
||||
|
||||
dataSql = f'SELECT * FROM "{table}"{where_clause}{order_clause}{limit_clause}'
|
||||
cursor.execute(dataSql, values)
|
||||
records = [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
fields = _get_model_fields(model_class)
|
||||
modelFields = model_class.model_fields
|
||||
for record in records:
|
||||
_parseRecordFields(record, fields, f"table {table}")
|
||||
for fieldName, fieldType in fields.items():
|
||||
if fieldType == "JSONB" and fieldName in record and record[fieldName] is None:
|
||||
fieldInfo = modelFields.get(fieldName)
|
||||
if fieldInfo:
|
||||
fieldAnnotation = fieldInfo.annotation
|
||||
if (fieldAnnotation == list or
|
||||
(hasattr(fieldAnnotation, "__origin__") and
|
||||
fieldAnnotation.__origin__ is list)):
|
||||
record[fieldName] = []
|
||||
elif (fieldAnnotation == dict or
|
||||
(hasattr(fieldAnnotation, "__origin__") and
|
||||
fieldAnnotation.__origin__ is dict)):
|
||||
record[fieldName] = {}
|
||||
|
||||
if fieldFilter and isinstance(fieldFilter, list):
|
||||
records = [{f: r[f] for f in fieldFilter if f in r} for r in records]
|
||||
|
||||
pageSize = pagination.pageSize if pagination else max(totalItems, 1)
|
||||
totalPages = math.ceil(totalItems / pageSize) if totalItems > 0 else 0
|
||||
|
||||
return {"items": records, "totalItems": totalItems, "totalPages": totalPages}
|
||||
except Exception as e:
|
||||
logger.error(f"Error in getRecordsetPaginated for table {table}: {e}")
|
||||
return {"items": [], "totalItems": 0, "totalPages": 0}
|
||||
|
||||
def getDistinctColumnValues(
|
||||
self,
|
||||
model_class: type,
|
||||
column: str,
|
||||
pagination=None,
|
||||
recordFilter: Dict[str, Any] = None,
|
||||
) -> List[str]:
|
||||
"""
|
||||
Returns sorted distinct non-null values for a column using SQL DISTINCT.
|
||||
Applies cross-filtering (all filters except the requested column).
|
||||
"""
|
||||
table = model_class.__name__
|
||||
fields = _get_model_fields(model_class)
|
||||
|
||||
if column not in fields:
|
||||
return []
|
||||
|
||||
try:
|
||||
if not self._ensureTableExists(model_class):
|
||||
return []
|
||||
|
||||
if pagination:
|
||||
if pagination.filters and column in pagination.filters:
|
||||
import copy
|
||||
pagination = copy.deepcopy(pagination)
|
||||
pagination.filters.pop(column, None)
|
||||
|
||||
where_clause, _, _, values, _ = \
|
||||
self._buildPaginationClauses(model_class, pagination, recordFilter)
|
||||
|
||||
sql = (
|
||||
f'SELECT DISTINCT "{column}"::TEXT AS val FROM "{table}"{where_clause} '
|
||||
f'WHERE "{column}" IS NOT NULL AND "{column}"::TEXT != \'\' '
|
||||
if not where_clause else
|
||||
f'SELECT DISTINCT "{column}"::TEXT AS val FROM "{table}"{where_clause} '
|
||||
f'AND "{column}" IS NOT NULL AND "{column}"::TEXT != \'\' '
|
||||
)
|
||||
sql += 'ORDER BY val'
|
||||
|
||||
with self.connection.cursor() as cursor:
|
||||
cursor.execute(sql, values)
|
||||
return [row["val"] for row in cursor.fetchall()]
|
||||
except Exception as e:
|
||||
logger.error(f"Error in getDistinctColumnValues for {table}.{column}: {e}")
|
||||
return []
|
||||
|
||||
def recordCreate(
|
||||
self, model_class: type, record: Union[Dict[str, Any], BaseModel]
|
||||
) -> Dict[str, Any]:
|
||||
|
|
|
|||
|
|
@ -98,6 +98,12 @@ def normalize_pagination_dict(pagination_dict: Dict[str, Any]) -> Dict[str, Any]
|
|||
# Create a copy to avoid modifying the original
|
||||
normalized = dict(pagination_dict)
|
||||
|
||||
# Ensure required fields have sensible defaults
|
||||
if "page" not in normalized:
|
||||
normalized["page"] = 1
|
||||
if "pageSize" not in normalized:
|
||||
normalized["pageSize"] = 25
|
||||
|
||||
# Move top-level "search" into filters if present
|
||||
if "search" in normalized:
|
||||
if "filters" not in normalized or normalized["filters"] is None:
|
||||
|
|
|
|||
|
|
@ -270,7 +270,7 @@ class AutomationObjects:
|
|||
if value.lower() not in itemValue.lower():
|
||||
match = False
|
||||
break
|
||||
elif itemValue != value:
|
||||
elif str(itemValue).lower() != str(value).lower():
|
||||
match = False
|
||||
break
|
||||
if match:
|
||||
|
|
|
|||
|
|
@ -109,6 +109,26 @@ def get_automations(
|
|||
detail=f"Error getting automations: {str(e)}"
|
||||
)
|
||||
|
||||
@router.get("/filter-values")
|
||||
@limiter.limit("60/minute")
|
||||
def get_automation_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 automations."""
|
||||
try:
|
||||
from modules.routes.routeDataUsers import _handleFilterValuesRequest
|
||||
chatInterface = getAutomationInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None)
|
||||
result = chatInterface.getAllAutomationDefinitions(pagination=None)
|
||||
items = result if isinstance(result, list) else [r if isinstance(r, dict) else 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 automations: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("", response_model=AutomationDefinition)
|
||||
@limiter.limit("10/minute")
|
||||
def create_automation(
|
||||
|
|
@ -1017,6 +1037,30 @@ def get_db_templates(
|
|||
)
|
||||
|
||||
|
||||
@templateRouter.get("/filter-values")
|
||||
@limiter.limit("60/minute")
|
||||
def get_template_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 automation templates."""
|
||||
try:
|
||||
from modules.routes.routeDataUsers import _handleFilterValuesRequest
|
||||
chatInterface = getAutomationInterface(
|
||||
context.user,
|
||||
mandateId=str(context.mandateId) if context.mandateId else None,
|
||||
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
|
||||
)
|
||||
result = chatInterface.getAllAutomationTemplates(pagination=None)
|
||||
items = [r if isinstance(r, dict) else 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 automation templates: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@templateRouter.get("/attributes", response_model=Dict[str, Any])
|
||||
def get_template_attributes(
|
||||
request: Request
|
||||
|
|
|
|||
|
|
@ -514,7 +514,7 @@ class ChatObjects:
|
|||
|
||||
# Handle simple value (equals operator)
|
||||
if not isinstance(filter_value, dict):
|
||||
if record_value != filter_value:
|
||||
if str(record_value).lower() != str(filter_value).lower():
|
||||
matches = False
|
||||
break
|
||||
continue
|
||||
|
|
@ -524,7 +524,7 @@ class ChatObjects:
|
|||
filter_val = filter_value.get("value")
|
||||
|
||||
if operator in ["equals", "eq"]:
|
||||
if record_value != filter_val:
|
||||
if str(record_value).lower() != str(filter_val).lower():
|
||||
matches = False
|
||||
break
|
||||
|
||||
|
|
@ -609,7 +609,7 @@ class ChatObjects:
|
|||
|
||||
else:
|
||||
# Unknown operator - default to equals
|
||||
if record_value != filter_val:
|
||||
if str(record_value).lower() != str(filter_val).lower():
|
||||
matches = False
|
||||
break
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ from modules.shared.configuration import APP_CONFIG
|
|||
from modules.security.rbac import RbacClass
|
||||
from modules.datamodels.datamodelRbac import AccessRuleContext
|
||||
from modules.datamodels.datamodelUam import AccessLevel
|
||||
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
|
||||
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC, getRecordsetPaginatedWithRBAC, getDistinctColumnValuesWithRBAC
|
||||
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -175,17 +176,21 @@ class RealEstateObjects:
|
|||
|
||||
return Projekt(**records[0])
|
||||
|
||||
def getProjekte(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Projekt]:
|
||||
"""Get all projects matching the filter."""
|
||||
records = getRecordsetWithRBAC(
|
||||
def getProjekte(self, recordFilter: Optional[Dict[str, Any]] = None, pagination: Optional[PaginationParams] = None) -> Union[List[Projekt], PaginatedResult]:
|
||||
"""Get all projects matching the filter with optional DB-level pagination."""
|
||||
result = getRecordsetPaginatedWithRBAC(
|
||||
self.db,
|
||||
Projekt,
|
||||
self.currentUser,
|
||||
pagination=pagination,
|
||||
recordFilter=recordFilter or {},
|
||||
featureCode=self.FEATURE_CODE
|
||||
)
|
||||
|
||||
return [Projekt(**r) for r in records]
|
||||
|
||||
if isinstance(result, PaginatedResult):
|
||||
result.items = [Projekt(**r) for r in result.items]
|
||||
return result
|
||||
return [Projekt(**r) for r in result]
|
||||
|
||||
def updateProjekt(self, projektId_or_projekt: Union[str, Projekt], updateData: Optional[Dict[str, Any]] = None) -> Optional[Projekt]:
|
||||
"""Update a project.
|
||||
|
|
@ -265,20 +270,23 @@ class RealEstateObjects:
|
|||
|
||||
return Parzelle(**records[0])
|
||||
|
||||
def getParzellen(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Parzelle]:
|
||||
"""Get all plots matching the filter."""
|
||||
# Resolve location names to IDs if needed
|
||||
def getParzellen(self, recordFilter: Optional[Dict[str, Any]] = None, pagination: Optional[PaginationParams] = None) -> Union[List[Parzelle], PaginatedResult]:
|
||||
"""Get all plots matching the filter with optional DB-level pagination."""
|
||||
if recordFilter:
|
||||
recordFilter = self._resolveLocationFilters(recordFilter)
|
||||
|
||||
records = getRecordsetWithRBAC(
|
||||
|
||||
result = getRecordsetPaginatedWithRBAC(
|
||||
self.db,
|
||||
Parzelle,
|
||||
self.currentUser,
|
||||
pagination=pagination,
|
||||
recordFilter=recordFilter or {}
|
||||
)
|
||||
|
||||
return [Parzelle(**r) for r in records]
|
||||
|
||||
if isinstance(result, PaginatedResult):
|
||||
result.items = [Parzelle(**r) for r in result.items]
|
||||
return result
|
||||
return [Parzelle(**r) for r in result]
|
||||
|
||||
def _resolveLocationFilters(self, recordFilter: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
|
|
@ -477,18 +485,23 @@ class RealEstateObjects:
|
|||
|
||||
return Dokument(**records[0])
|
||||
|
||||
def getDokumente(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Dokument]:
|
||||
"""Get all documents matching the filter."""
|
||||
records = getRecordsetWithRBAC(
|
||||
def getDokumente(self, recordFilter: Optional[Dict[str, Any]] = None, pagination: Optional[PaginationParams] = None) -> Union[List[Dokument], PaginatedResult]:
|
||||
"""Get all documents matching the filter with optional DB-level pagination."""
|
||||
result = getRecordsetPaginatedWithRBAC(
|
||||
self.db,
|
||||
Dokument,
|
||||
self.currentUser,
|
||||
pagination=pagination,
|
||||
recordFilter=recordFilter or {},
|
||||
mandateId=self.mandateId,
|
||||
featureInstanceId=self.featureInstanceId,
|
||||
featureCode=self.FEATURE_CODE
|
||||
)
|
||||
return [Dokument(**r) for r in records]
|
||||
|
||||
if isinstance(result, PaginatedResult):
|
||||
result.items = [Dokument(**r) for r in result.items]
|
||||
return result
|
||||
return [Dokument(**r) for r in result]
|
||||
|
||||
def updateDokument(self, dokumentId: str, updateData: Dict[str, Any]) -> Optional[Dokument]:
|
||||
"""Update a document."""
|
||||
|
|
@ -552,18 +565,23 @@ class RealEstateObjects:
|
|||
|
||||
return Gemeinde(**records[0])
|
||||
|
||||
def getGemeinden(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Gemeinde]:
|
||||
"""Get all municipalities matching the filter."""
|
||||
records = getRecordsetWithRBAC(
|
||||
def getGemeinden(self, recordFilter: Optional[Dict[str, Any]] = None, pagination: Optional[PaginationParams] = None) -> Union[List[Gemeinde], PaginatedResult]:
|
||||
"""Get all municipalities matching the filter with optional DB-level pagination."""
|
||||
result = getRecordsetPaginatedWithRBAC(
|
||||
self.db,
|
||||
Gemeinde,
|
||||
self.currentUser,
|
||||
pagination=pagination,
|
||||
recordFilter=recordFilter or {},
|
||||
mandateId=self.mandateId,
|
||||
featureInstanceId=self.featureInstanceId,
|
||||
featureCode=self.FEATURE_CODE
|
||||
)
|
||||
return [Gemeinde(**r) for r in records]
|
||||
|
||||
if isinstance(result, PaginatedResult):
|
||||
result.items = [Gemeinde(**r) for r in result.items]
|
||||
return result
|
||||
return [Gemeinde(**r) for r in result]
|
||||
|
||||
def updateGemeinde(self, gemeindeId: str, updateData: Dict[str, Any]) -> Optional[Gemeinde]:
|
||||
"""Update a municipality."""
|
||||
|
|
@ -627,18 +645,23 @@ class RealEstateObjects:
|
|||
|
||||
return Kanton(**records[0])
|
||||
|
||||
def getKantone(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Kanton]:
|
||||
"""Get all cantons matching the filter."""
|
||||
records = getRecordsetWithRBAC(
|
||||
def getKantone(self, recordFilter: Optional[Dict[str, Any]] = None, pagination: Optional[PaginationParams] = None) -> Union[List[Kanton], PaginatedResult]:
|
||||
"""Get all cantons matching the filter with optional DB-level pagination."""
|
||||
result = getRecordsetPaginatedWithRBAC(
|
||||
self.db,
|
||||
Kanton,
|
||||
self.currentUser,
|
||||
pagination=pagination,
|
||||
recordFilter=recordFilter or {},
|
||||
mandateId=self.mandateId,
|
||||
featureInstanceId=self.featureInstanceId,
|
||||
featureCode=self.FEATURE_CODE
|
||||
)
|
||||
return [Kanton(**r) for r in records]
|
||||
|
||||
if isinstance(result, PaginatedResult):
|
||||
result.items = [Kanton(**r) for r in result.items]
|
||||
return result
|
||||
return [Kanton(**r) for r in result]
|
||||
|
||||
def updateKanton(self, kantonId: str, updateData: Dict[str, Any]) -> Optional[Kanton]:
|
||||
"""Update a canton."""
|
||||
|
|
@ -700,16 +723,21 @@ class RealEstateObjects:
|
|||
|
||||
return Land(**records[0])
|
||||
|
||||
def getLaender(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Land]:
|
||||
"""Get all countries matching the filter."""
|
||||
records = getRecordsetWithRBAC(
|
||||
def getLaender(self, recordFilter: Optional[Dict[str, Any]] = None, pagination: Optional[PaginationParams] = None) -> Union[List[Land], PaginatedResult]:
|
||||
"""Get all countries matching the filter with optional DB-level pagination."""
|
||||
result = getRecordsetPaginatedWithRBAC(
|
||||
self.db,
|
||||
Land,
|
||||
self.currentUser,
|
||||
pagination=pagination,
|
||||
recordFilter=recordFilter or {},
|
||||
featureCode=self.FEATURE_CODE
|
||||
)
|
||||
return [Land(**r) for r in records]
|
||||
|
||||
if isinstance(result, PaginatedResult):
|
||||
result.items = [Land(**r) for r in result.items]
|
||||
return result
|
||||
return [Land(**r) for r in result]
|
||||
|
||||
def updateLand(self, landId: str, updateData: Dict[str, Any]) -> Optional[Land]:
|
||||
"""Update a country."""
|
||||
|
|
|
|||
|
|
@ -252,6 +252,31 @@ def get_projects(
|
|||
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)
|
||||
@limiter.limit("30/minute")
|
||||
def get_project_by_id(
|
||||
|
|
@ -384,6 +409,31 @@ def get_parcels(
|
|||
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)
|
||||
@limiter.limit("30/minute")
|
||||
def get_parcel_by_id(
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ from pydantic import ValidationError
|
|||
|
||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||
from modules.shared.configuration import APP_CONFIG
|
||||
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
|
||||
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC, getRecordsetPaginatedWithRBAC, getDistinctColumnValuesWithRBAC
|
||||
from modules.security.rbac import RbacClass
|
||||
from modules.datamodels.datamodelUam import User, AccessLevel
|
||||
from modules.datamodels.datamodelRbac import AccessRuleContext
|
||||
|
|
@ -414,7 +414,7 @@ class TrusteeObjects:
|
|||
|
||||
# Handle simple value (equals operator)
|
||||
if not isinstance(filterValue, dict):
|
||||
if recordValue == filterValue:
|
||||
if str(recordValue).lower() == str(filterValue).lower():
|
||||
fieldFiltered.append(record)
|
||||
continue
|
||||
|
||||
|
|
@ -424,7 +424,7 @@ class TrusteeObjects:
|
|||
|
||||
matches = False
|
||||
if operator in ["equals", "eq"]:
|
||||
matches = recordValue == filterVal
|
||||
matches = str(recordValue).lower() == str(filterVal).lower()
|
||||
|
||||
elif operator == "contains":
|
||||
recordStr = str(recordValue).lower() if recordValue is not None else ""
|
||||
|
|
@ -577,8 +577,8 @@ class TrusteeObjects:
|
|||
return None
|
||||
return TrusteeOrganisation(**{k: v for k, v in records[0].items() if not k.startswith("_")})
|
||||
|
||||
def getAllOrganisations(self, params: Optional[PaginationParams] = None) -> PaginatedResult:
|
||||
"""Get all organisations with RBAC filtering.
|
||||
def getAllOrganisations(self, params: Optional[PaginationParams] = None) -> Union[List[Dict], PaginatedResult]:
|
||||
"""Get all organisations with RBAC filtering and optional DB-level pagination.
|
||||
|
||||
Note: Organisations are managed at system level (by mandate).
|
||||
Feature-level filtering (trustee.access) is NOT applied here because:
|
||||
|
|
@ -586,42 +586,18 @@ class TrusteeObjects:
|
|||
- trustee.access grants access to specific orgs for other users
|
||||
- New organisations wouldn't be visible without an access record
|
||||
"""
|
||||
# Debug: Log user info and permissions
|
||||
logger.debug(f"getAllOrganisations called for user {self.userId}, mandateId: {self.mandateId}")
|
||||
|
||||
# System RBAC filtering (filters by mandate for GROUP access level)
|
||||
records = getRecordsetWithRBAC(
|
||||
return getRecordsetPaginatedWithRBAC(
|
||||
connector=self.db,
|
||||
modelClass=TrusteeOrganisation,
|
||||
currentUser=self.currentUser,
|
||||
pagination=params,
|
||||
recordFilter=None,
|
||||
orderBy="id",
|
||||
mandateId=self.mandateId,
|
||||
featureInstanceId=self.featureInstanceId,
|
||||
featureCode=self.FEATURE_CODE
|
||||
)
|
||||
logger.debug(f"getAllOrganisations: getRecordsetWithRBAC returned {len(records)} records")
|
||||
|
||||
# Apply pagination
|
||||
totalItems = len(records)
|
||||
if params:
|
||||
pageSize = params.pageSize or 20
|
||||
page = params.page or 1
|
||||
startIdx = (page - 1) * pageSize
|
||||
endIdx = startIdx + pageSize
|
||||
items = records[startIdx:endIdx]
|
||||
totalPages = math.ceil(totalItems / pageSize) if pageSize > 0 else 1
|
||||
else:
|
||||
items = records
|
||||
totalPages = 1
|
||||
page = 1
|
||||
pageSize = totalItems
|
||||
|
||||
return PaginatedResult(
|
||||
items=items,
|
||||
totalItems=totalItems,
|
||||
totalPages=totalPages
|
||||
)
|
||||
|
||||
def updateOrganisation(self, orgId: str, data: Dict[str, Any]) -> Optional[TrusteeOrganisation]:
|
||||
"""Update an organisation."""
|
||||
|
|
@ -677,51 +653,40 @@ class TrusteeObjects:
|
|||
return None
|
||||
return TrusteeRole(**{k: v for k, v in records[0].items() if not k.startswith("_")})
|
||||
|
||||
def getAllRoles(self, params: Optional[PaginationParams] = None) -> PaginatedResult:
|
||||
"""Get all roles with RBAC filtering.
|
||||
def getAllRoles(self, params: Optional[PaginationParams] = None) -> Union[List[Dict], PaginatedResult]:
|
||||
"""Get all roles with RBAC filtering and optional DB-level pagination.
|
||||
|
||||
Note: Roles are available to all users with trustee access.
|
||||
They are not filtered by organisation since they define the role types.
|
||||
Users with ALL access level see all roles; others need trustee.access records.
|
||||
|
||||
NOTE(post-filter): Feature-level access check runs after the paginated query.
|
||||
When pagination is active the totals may overcount if the user lacks any
|
||||
trustee.access record (in that case the entire result is emptied).
|
||||
"""
|
||||
records = getRecordsetWithRBAC(
|
||||
result = getRecordsetPaginatedWithRBAC(
|
||||
connector=self.db,
|
||||
modelClass=TrusteeRole,
|
||||
currentUser=self.currentUser,
|
||||
pagination=params,
|
||||
recordFilter=None,
|
||||
orderBy="id",
|
||||
mandateId=self.mandateId,
|
||||
featureInstanceId=self.featureInstanceId,
|
||||
featureCode=self.FEATURE_CODE
|
||||
)
|
||||
|
||||
# Users with ALL access level (from system RBAC) see all roles
|
||||
# Others need at least one trustee.access record
|
||||
accessLevel = self.getRbacAccessLevel(TrusteeRole, "read")
|
||||
if accessLevel != AccessLevel.ALL:
|
||||
userAccess = self.getAllUserAccess(self.userId)
|
||||
if not userAccess:
|
||||
records = [] # No trustee access at all
|
||||
if isinstance(result, PaginatedResult):
|
||||
result.items = []
|
||||
result.totalItems = 0
|
||||
result.totalPages = 0
|
||||
return result
|
||||
return []
|
||||
|
||||
totalItems = len(records)
|
||||
if params:
|
||||
pageSize = params.pageSize or 20
|
||||
page = params.page or 1
|
||||
startIdx = (page - 1) * pageSize
|
||||
endIdx = startIdx + pageSize
|
||||
items = records[startIdx:endIdx]
|
||||
totalPages = math.ceil(totalItems / pageSize) if pageSize > 0 else 1
|
||||
else:
|
||||
items = records
|
||||
totalPages = 1
|
||||
page = 1
|
||||
pageSize = totalItems
|
||||
|
||||
return PaginatedResult(
|
||||
items=items,
|
||||
totalItems=totalItems,
|
||||
totalPages=totalPages
|
||||
)
|
||||
return result
|
||||
|
||||
def updateRole(self, roleId: str, data: Dict[str, Any]) -> Optional[TrusteeRole]:
|
||||
"""Update a role (sysadmin only)."""
|
||||
|
|
@ -788,116 +753,113 @@ class TrusteeObjects:
|
|||
return None
|
||||
return TrusteeAccess(**{k: v for k, v in records[0].items() if not k.startswith("_")})
|
||||
|
||||
def getAllAccess(self, params: Optional[PaginationParams] = None) -> PaginatedResult:
|
||||
def getAllAccess(self, params: Optional[PaginationParams] = None) -> Union[List[Dict], PaginatedResult]:
|
||||
"""Get all access records with RBAC filtering + feature-level filtering.
|
||||
|
||||
Users with ALL access level see all access records.
|
||||
Others can only see access records for organisations they have admin access to.
|
||||
|
||||
NOTE(post-filter): Feature-level admin-org filtering runs after the paginated
|
||||
query, so totals may overcount when the user has restricted org access.
|
||||
"""
|
||||
# Step 1: System RBAC filtering
|
||||
records = getRecordsetWithRBAC(
|
||||
result = getRecordsetPaginatedWithRBAC(
|
||||
connector=self.db,
|
||||
modelClass=TrusteeAccess,
|
||||
currentUser=self.currentUser,
|
||||
pagination=params,
|
||||
recordFilter=None,
|
||||
orderBy="id",
|
||||
mandateId=self.mandateId,
|
||||
featureInstanceId=self.featureInstanceId,
|
||||
featureCode=self.FEATURE_CODE
|
||||
)
|
||||
|
||||
# Users with ALL access level (from system RBAC) see all records
|
||||
accessLevel = self.getRbacAccessLevel(TrusteeAccess, "read")
|
||||
|
||||
|
||||
if accessLevel != AccessLevel.ALL:
|
||||
# Step 2: Feature-level filtering - only see access for organisations where user is admin
|
||||
userAccess = self.getAllUserAccess(self.userId)
|
||||
|
||||
# Get organisations where user has admin role
|
||||
adminOrgs = set()
|
||||
for access in userAccess:
|
||||
if access.get("roleId") == "admin":
|
||||
adminOrgs.add(access.get("organisationId"))
|
||||
|
||||
# Filter records to only show those in admin organisations
|
||||
|
||||
if isinstance(result, PaginatedResult):
|
||||
if adminOrgs:
|
||||
result.items = [r for r in result.items if r.get("organisationId") in adminOrgs]
|
||||
else:
|
||||
result.items = []
|
||||
return result
|
||||
|
||||
if adminOrgs:
|
||||
records = [r for r in records if r.get("organisationId") in adminOrgs]
|
||||
result = [r for r in result if r.get("organisationId") in adminOrgs]
|
||||
else:
|
||||
records = []
|
||||
result = []
|
||||
|
||||
totalItems = len(records)
|
||||
if params:
|
||||
pageSize = params.pageSize or 20
|
||||
page = params.page or 1
|
||||
startIdx = (page - 1) * pageSize
|
||||
endIdx = startIdx + pageSize
|
||||
items = records[startIdx:endIdx]
|
||||
totalPages = math.ceil(totalItems / pageSize) if pageSize > 0 else 1
|
||||
else:
|
||||
items = records
|
||||
totalPages = 1
|
||||
page = 1
|
||||
pageSize = totalItems
|
||||
return result
|
||||
|
||||
return PaginatedResult(
|
||||
items=items,
|
||||
totalItems=totalItems,
|
||||
totalPages=totalPages
|
||||
)
|
||||
|
||||
def getAccessByOrganisation(self, organisationId: str) -> List[TrusteeAccess]:
|
||||
def getAccessByOrganisation(self, organisationId: str, pagination: Optional[PaginationParams] = None) -> Union[List[TrusteeAccess], PaginatedResult]:
|
||||
"""Get all access records for a specific organisation.
|
||||
|
||||
Requires admin role for the organisation.
|
||||
"""
|
||||
# Check if user has admin access for this organisation
|
||||
if not self.checkUserTrusteePermission(self.userId, organisationId, "admin"):
|
||||
logger.warning(f"User {self.userId} lacks admin role for organisation {organisationId}")
|
||||
return []
|
||||
|
||||
records = getRecordsetWithRBAC(
|
||||
|
||||
result = getRecordsetPaginatedWithRBAC(
|
||||
connector=self.db,
|
||||
modelClass=TrusteeAccess,
|
||||
currentUser=self.currentUser,
|
||||
pagination=pagination,
|
||||
recordFilter={"organisationId": organisationId},
|
||||
orderBy="id",
|
||||
mandateId=self.mandateId,
|
||||
featureInstanceId=self.featureInstanceId,
|
||||
featureCode=self.FEATURE_CODE
|
||||
)
|
||||
return [TrusteeAccess(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in records]
|
||||
|
||||
def getAccessByUser(self, userId: str) -> List[TrusteeAccess]:
|
||||
if isinstance(result, PaginatedResult):
|
||||
result.items = [TrusteeAccess(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in result.items]
|
||||
return result
|
||||
return [TrusteeAccess(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in result]
|
||||
|
||||
def getAccessByUser(self, userId: str, pagination: Optional[PaginationParams] = None) -> Union[List[TrusteeAccess], PaginatedResult]:
|
||||
"""Get all access records for a specific user.
|
||||
|
||||
Users with ALL access level see all access records.
|
||||
Others can only see access records for organisations where they have admin role.
|
||||
|
||||
NOTE(post-filter): Admin-org filtering runs after the paginated query,
|
||||
so totals may overcount when the user has restricted org access.
|
||||
"""
|
||||
records = getRecordsetWithRBAC(
|
||||
result = getRecordsetPaginatedWithRBAC(
|
||||
connector=self.db,
|
||||
modelClass=TrusteeAccess,
|
||||
currentUser=self.currentUser,
|
||||
pagination=pagination,
|
||||
recordFilter={"userId": userId},
|
||||
orderBy="id",
|
||||
mandateId=self.mandateId,
|
||||
featureInstanceId=self.featureInstanceId,
|
||||
featureCode=self.FEATURE_CODE
|
||||
)
|
||||
|
||||
# Users with ALL access level (from system RBAC) see all records
|
||||
|
||||
accessLevel = self.getRbacAccessLevel(TrusteeAccess, "read")
|
||||
if accessLevel == AccessLevel.ALL:
|
||||
return [TrusteeAccess(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in records]
|
||||
|
||||
# Filter to only organisations where current user has admin role
|
||||
userAccess = self.getAllUserAccess(self.userId)
|
||||
adminOrgs = set()
|
||||
for access in userAccess:
|
||||
if access.get("roleId") == "admin":
|
||||
adminOrgs.add(access.get("organisationId"))
|
||||
|
||||
filtered = [r for r in records if r.get("organisationId") in adminOrgs]
|
||||
return [TrusteeAccess(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in filtered]
|
||||
|
||||
if accessLevel != AccessLevel.ALL:
|
||||
userAccess = self.getAllUserAccess(self.userId)
|
||||
adminOrgs = set()
|
||||
for access in userAccess:
|
||||
if access.get("roleId") == "admin":
|
||||
adminOrgs.add(access.get("organisationId"))
|
||||
|
||||
if isinstance(result, PaginatedResult):
|
||||
result.items = [r for r in result.items if r.get("organisationId") in adminOrgs]
|
||||
result.items = [TrusteeAccess(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in result.items]
|
||||
return result
|
||||
result = [r for r in result if r.get("organisationId") in adminOrgs]
|
||||
|
||||
if isinstance(result, PaginatedResult):
|
||||
result.items = [TrusteeAccess(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in result.items]
|
||||
return result
|
||||
return [TrusteeAccess(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in result]
|
||||
|
||||
def updateAccess(self, accessId: str, data: Dict[str, Any]) -> Optional[TrusteeAccess]:
|
||||
"""Update an access record. Requires admin role for the organisation or ALL access level."""
|
||||
|
|
@ -988,54 +950,36 @@ class TrusteeObjects:
|
|||
return None
|
||||
return TrusteeContract(**{k: v for k, v in records[0].items() if not k.startswith("_")})
|
||||
|
||||
def getAllContracts(self, params: Optional[PaginationParams] = None) -> PaginatedResult:
|
||||
"""Get all contracts with RBAC filtering + feature-level access filtering."""
|
||||
# Step 1: System RBAC filtering
|
||||
records = getRecordsetWithRBAC(
|
||||
def getAllContracts(self, params: Optional[PaginationParams] = None) -> Union[List[Dict], PaginatedResult]:
|
||||
"""Get all contracts with RBAC filtering and optional DB-level pagination."""
|
||||
return getRecordsetPaginatedWithRBAC(
|
||||
connector=self.db,
|
||||
modelClass=TrusteeContract,
|
||||
currentUser=self.currentUser,
|
||||
pagination=params,
|
||||
recordFilter=None,
|
||||
orderBy="id",
|
||||
mandateId=self.mandateId,
|
||||
featureInstanceId=self.featureInstanceId,
|
||||
featureCode=self.FEATURE_CODE
|
||||
)
|
||||
|
||||
totalItems = len(records)
|
||||
if params:
|
||||
pageSize = params.pageSize or 20
|
||||
page = params.page or 1
|
||||
startIdx = (page - 1) * pageSize
|
||||
endIdx = startIdx + pageSize
|
||||
items = records[startIdx:endIdx]
|
||||
totalPages = math.ceil(totalItems / pageSize) if pageSize > 0 else 1
|
||||
else:
|
||||
items = records
|
||||
totalPages = 1
|
||||
page = 1
|
||||
pageSize = totalItems
|
||||
|
||||
return PaginatedResult(
|
||||
items=items,
|
||||
totalItems=totalItems,
|
||||
totalPages=totalPages
|
||||
)
|
||||
|
||||
def getContractsByOrganisation(self, organisationId: str) -> List[TrusteeContract]:
|
||||
def getContractsByOrganisation(self, organisationId: str, pagination: Optional[PaginationParams] = None) -> Union[List[TrusteeContract], PaginatedResult]:
|
||||
"""Get all contracts for a specific organisation."""
|
||||
# Step 1: System RBAC filtering
|
||||
records = getRecordsetWithRBAC(
|
||||
result = getRecordsetPaginatedWithRBAC(
|
||||
connector=self.db,
|
||||
modelClass=TrusteeContract,
|
||||
currentUser=self.currentUser,
|
||||
pagination=pagination,
|
||||
recordFilter={"organisationId": organisationId},
|
||||
orderBy="label",
|
||||
mandateId=self.mandateId,
|
||||
featureInstanceId=self.featureInstanceId,
|
||||
featureCode=self.FEATURE_CODE
|
||||
)
|
||||
return [TrusteeContract(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in records]
|
||||
|
||||
if isinstance(result, PaginatedResult):
|
||||
result.items = [TrusteeContract(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in result.items]
|
||||
return result
|
||||
return [TrusteeContract(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in result]
|
||||
|
||||
def updateContract(self, contractId: str, data: Dict[str, Any]) -> Optional[TrusteeContract]:
|
||||
"""Update a contract (organisationId is immutable)."""
|
||||
|
|
@ -1142,75 +1086,58 @@ class TrusteeObjects:
|
|||
# Legacy fallback: documentData was stored directly (for migration)
|
||||
return record.get("documentData")
|
||||
|
||||
def getAllDocuments(self, params: Optional[PaginationParams] = None) -> PaginatedResult:
|
||||
"""Get all documents with RBAC filtering + feature-level access filtering (metadata only)."""
|
||||
# Step 1: System RBAC filtering
|
||||
records = getRecordsetWithRBAC(
|
||||
def getAllDocuments(self, params: Optional[PaginationParams] = None) -> Union[List[Dict], PaginatedResult]:
|
||||
"""Get all documents with RBAC filtering and optional DB-level pagination (metadata only).
|
||||
|
||||
Filtering, sorting, and pagination are handled at the SQL level by
|
||||
getRecordsetPaginatedWithRBAC. Binary documentData is stripped from
|
||||
the returned items.
|
||||
"""
|
||||
result = getRecordsetPaginatedWithRBAC(
|
||||
connector=self.db,
|
||||
modelClass=TrusteeDocument,
|
||||
currentUser=self.currentUser,
|
||||
pagination=params,
|
||||
recordFilter=None,
|
||||
orderBy="documentName",
|
||||
mandateId=self.mandateId,
|
||||
featureInstanceId=self.featureInstanceId,
|
||||
featureCode=self.FEATURE_CODE
|
||||
)
|
||||
|
||||
# Clean records (remove binary data and internal fields) - keep as dicts for filtering/sorting
|
||||
cleanedRecords = []
|
||||
for record in records:
|
||||
cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_") and k != "documentData"}
|
||||
cleanedRecords.append(cleanedRecord)
|
||||
def _cleanDocumentRecords(records):
|
||||
return [
|
||||
TrusteeDocument(**{k: v for k, v in r.items() if not k.startswith("_") and k != "documentData"})
|
||||
for r in records
|
||||
]
|
||||
|
||||
# Step 2: Apply filters (search and field filters)
|
||||
filteredRecords = self._applyFilters(cleanedRecords, params)
|
||||
|
||||
# Step 3: Apply sorting
|
||||
sortedRecords = self._applySorting(filteredRecords, params)
|
||||
|
||||
# Step 4: Convert to Pydantic objects
|
||||
pydanticItems = [TrusteeDocument(**r) for r in sortedRecords]
|
||||
if isinstance(result, PaginatedResult):
|
||||
result.items = _cleanDocumentRecords(result.items)
|
||||
return result
|
||||
return _cleanDocumentRecords(result)
|
||||
|
||||
# Step 5: Apply pagination
|
||||
totalItems = len(pydanticItems)
|
||||
if params:
|
||||
pageSize = params.pageSize or 20
|
||||
page = params.page or 1
|
||||
startIdx = (page - 1) * pageSize
|
||||
endIdx = startIdx + pageSize
|
||||
items = pydanticItems[startIdx:endIdx]
|
||||
totalPages = math.ceil(totalItems / pageSize) if pageSize > 0 else 1
|
||||
else:
|
||||
items = pydanticItems
|
||||
totalPages = 1
|
||||
page = 1
|
||||
pageSize = totalItems
|
||||
|
||||
return PaginatedResult(
|
||||
items=items,
|
||||
totalItems=totalItems,
|
||||
totalPages=totalPages
|
||||
)
|
||||
|
||||
def getDocumentsByContract(self, contractId: str) -> List[TrusteeDocument]:
|
||||
def getDocumentsByContract(self, contractId: str, pagination: Optional[PaginationParams] = None) -> Union[List[TrusteeDocument], PaginatedResult]:
|
||||
"""Get all documents for a specific contract."""
|
||||
# Step 1: System RBAC filtering
|
||||
records = getRecordsetWithRBAC(
|
||||
result = getRecordsetPaginatedWithRBAC(
|
||||
connector=self.db,
|
||||
modelClass=TrusteeDocument,
|
||||
currentUser=self.currentUser,
|
||||
pagination=pagination,
|
||||
recordFilter={"contractId": contractId},
|
||||
orderBy="documentName",
|
||||
mandateId=self.mandateId,
|
||||
featureInstanceId=self.featureInstanceId,
|
||||
featureCode=self.FEATURE_CODE
|
||||
)
|
||||
|
||||
result = []
|
||||
for record in records:
|
||||
cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_") and k != "documentData"}
|
||||
result.append(TrusteeDocument(**cleanedRecord))
|
||||
return result
|
||||
|
||||
def _cleanDocumentRecords(records):
|
||||
return [
|
||||
TrusteeDocument(**{k: v for k, v in r.items() if not k.startswith("_") and k != "documentData"})
|
||||
for r in records
|
||||
]
|
||||
|
||||
if isinstance(result, PaginatedResult):
|
||||
result.items = _cleanDocumentRecords(result.items)
|
||||
return result
|
||||
return _cleanDocumentRecords(result)
|
||||
|
||||
def updateDocument(self, documentId: str, data: Dict[str, Any]) -> Optional[TrusteeDocument]:
|
||||
"""Update a document.
|
||||
|
|
@ -1340,101 +1267,94 @@ class TrusteeObjects:
|
|||
return None
|
||||
return self._toTrusteePositionOrDelete(records[0], deleteCorrupt=True)
|
||||
|
||||
def getAllPositions(self, params: Optional[PaginationParams] = None) -> PaginatedResult:
|
||||
"""Get all positions with RBAC filtering + feature-level access filtering."""
|
||||
# Step 1: System RBAC filtering
|
||||
records = getRecordsetWithRBAC(
|
||||
def getAllPositions(self, params: Optional[PaginationParams] = None) -> Union[List[Dict], PaginatedResult]:
|
||||
"""Get all positions with RBAC filtering and optional DB-level pagination.
|
||||
|
||||
Filtering, sorting, and pagination are handled at the SQL level.
|
||||
Post-processing cleans internal fields (keeps _createdAt) and validates
|
||||
each record via _toTrusteePositionOrDelete (corrupt rows are deleted).
|
||||
|
||||
NOTE(post-process): totalItems may slightly overcount when corrupt legacy
|
||||
records are removed from the current page.
|
||||
"""
|
||||
result = getRecordsetPaginatedWithRBAC(
|
||||
connector=self.db,
|
||||
modelClass=TrusteePosition,
|
||||
currentUser=self.currentUser,
|
||||
pagination=params,
|
||||
recordFilter=None,
|
||||
orderBy="valuta",
|
||||
mandateId=self.mandateId,
|
||||
featureInstanceId=self.featureInstanceId,
|
||||
featureCode=self.FEATURE_CODE
|
||||
)
|
||||
|
||||
# Clean records (remove internal fields except _createdAt) - keep as dicts for filtering/sorting
|
||||
# Keep _createdAt for display in frontend
|
||||
keepFields = {'_createdAt'}
|
||||
cleanedRecords = []
|
||||
for record in records:
|
||||
cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_") or k in keepFields}
|
||||
cleanedRecords.append(cleanedRecord)
|
||||
|
||||
# Step 2: Apply filters (search and field filters)
|
||||
filteredRecords = self._applyFilters(cleanedRecords, params)
|
||||
|
||||
# Step 3: Apply sorting
|
||||
sortedRecords = self._applySorting(filteredRecords, params)
|
||||
|
||||
# Step 4: Convert to Pydantic objects and cleanup corrupt legacy records.
|
||||
pydanticItems = []
|
||||
for record in sortedRecords:
|
||||
position = self._toTrusteePositionOrDelete(record, deleteCorrupt=True)
|
||||
if position is not None:
|
||||
pydanticItems.append(position)
|
||||
def _cleanAndValidate(records):
|
||||
items = []
|
||||
for record in records:
|
||||
cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_") or k in keepFields}
|
||||
position = self._toTrusteePositionOrDelete(cleanedRecord, deleteCorrupt=True)
|
||||
if position is not None:
|
||||
items.append(position)
|
||||
return items
|
||||
|
||||
# Step 5: Apply pagination
|
||||
totalItems = len(pydanticItems)
|
||||
if params:
|
||||
pageSize = params.pageSize or 20
|
||||
page = params.page or 1
|
||||
startIdx = (page - 1) * pageSize
|
||||
endIdx = startIdx + pageSize
|
||||
items = pydanticItems[startIdx:endIdx]
|
||||
totalPages = math.ceil(totalItems / pageSize) if pageSize > 0 else 1
|
||||
else:
|
||||
items = pydanticItems
|
||||
totalPages = 1
|
||||
page = 1
|
||||
pageSize = totalItems
|
||||
if isinstance(result, PaginatedResult):
|
||||
result.items = _cleanAndValidate(result.items)
|
||||
return result
|
||||
return _cleanAndValidate(result)
|
||||
|
||||
return PaginatedResult(
|
||||
items=items,
|
||||
totalItems=totalItems,
|
||||
totalPages=totalPages
|
||||
)
|
||||
|
||||
def getPositionsByContract(self, contractId: str) -> List[TrusteePosition]:
|
||||
def getPositionsByContract(self, contractId: str, pagination: Optional[PaginationParams] = None) -> Union[List[TrusteePosition], PaginatedResult]:
|
||||
"""Get all positions for a specific contract."""
|
||||
# Step 1: System RBAC filtering
|
||||
records = getRecordsetWithRBAC(
|
||||
result = getRecordsetPaginatedWithRBAC(
|
||||
connector=self.db,
|
||||
modelClass=TrusteePosition,
|
||||
currentUser=self.currentUser,
|
||||
pagination=pagination,
|
||||
recordFilter={"contractId": contractId},
|
||||
orderBy="valuta",
|
||||
mandateId=self.mandateId,
|
||||
featureInstanceId=self.featureInstanceId,
|
||||
featureCode=self.FEATURE_CODE
|
||||
)
|
||||
safeItems = []
|
||||
for record in records:
|
||||
position = self._toTrusteePositionOrDelete(record, deleteCorrupt=True)
|
||||
if position is not None:
|
||||
safeItems.append(position)
|
||||
return safeItems
|
||||
|
||||
def getPositionsByOrganisation(self, organisationId: str) -> List[TrusteePosition]:
|
||||
def _validatePositions(records):
|
||||
items = []
|
||||
for record in records:
|
||||
position = self._toTrusteePositionOrDelete(record, deleteCorrupt=True)
|
||||
if position is not None:
|
||||
items.append(position)
|
||||
return items
|
||||
|
||||
if isinstance(result, PaginatedResult):
|
||||
result.items = _validatePositions(result.items)
|
||||
return result
|
||||
return _validatePositions(result)
|
||||
|
||||
def getPositionsByOrganisation(self, organisationId: str, pagination: Optional[PaginationParams] = None) -> Union[List[TrusteePosition], PaginatedResult]:
|
||||
"""Get all positions for a specific organisation."""
|
||||
# Step 1: System RBAC filtering
|
||||
records = getRecordsetWithRBAC(
|
||||
result = getRecordsetPaginatedWithRBAC(
|
||||
connector=self.db,
|
||||
modelClass=TrusteePosition,
|
||||
currentUser=self.currentUser,
|
||||
pagination=pagination,
|
||||
recordFilter={"organisationId": organisationId},
|
||||
orderBy="valuta",
|
||||
mandateId=self.mandateId,
|
||||
featureInstanceId=self.featureInstanceId,
|
||||
featureCode=self.FEATURE_CODE
|
||||
)
|
||||
safeItems = []
|
||||
for record in records:
|
||||
position = self._toTrusteePositionOrDelete(record, deleteCorrupt=True)
|
||||
if position is not None:
|
||||
safeItems.append(position)
|
||||
return safeItems
|
||||
|
||||
def _validatePositions(records):
|
||||
items = []
|
||||
for record in records:
|
||||
position = self._toTrusteePositionOrDelete(record, deleteCorrupt=True)
|
||||
if position is not None:
|
||||
items.append(position)
|
||||
return items
|
||||
|
||||
if isinstance(result, PaginatedResult):
|
||||
result.items = _validatePositions(result.items)
|
||||
return result
|
||||
return _validatePositions(result)
|
||||
|
||||
def updatePosition(self, positionId: str, data: Dict[str, Any]) -> Optional[TrusteePosition]:
|
||||
"""Update a position.
|
||||
|
|
@ -1481,24 +1401,31 @@ class TrusteeObjects:
|
|||
|
||||
# ===== Position-Document Queries =====
|
||||
|
||||
def getPositionsByDocument(self, documentId: str) -> List[TrusteePosition]:
|
||||
def getPositionsByDocument(self, documentId: str, pagination: Optional[PaginationParams] = None) -> Union[List[TrusteePosition], PaginatedResult]:
|
||||
"""Get all positions that reference a specific document (1:N via documentId FK)."""
|
||||
records = getRecordsetWithRBAC(
|
||||
result = getRecordsetPaginatedWithRBAC(
|
||||
connector=self.db,
|
||||
modelClass=TrusteePosition,
|
||||
currentUser=self.currentUser,
|
||||
pagination=pagination,
|
||||
recordFilter={"documentId": documentId},
|
||||
orderBy="valuta",
|
||||
mandateId=self.mandateId,
|
||||
featureInstanceId=self.featureInstanceId,
|
||||
featureCode=self.FEATURE_CODE
|
||||
)
|
||||
safeItems = []
|
||||
for record in records:
|
||||
position = self._toTrusteePositionOrDelete(record, deleteCorrupt=True)
|
||||
if position is not None:
|
||||
safeItems.append(position)
|
||||
return safeItems
|
||||
|
||||
def _validatePositions(records):
|
||||
items = []
|
||||
for record in records:
|
||||
position = self._toTrusteePositionOrDelete(record, deleteCorrupt=True)
|
||||
if position is not None:
|
||||
items.append(position)
|
||||
return items
|
||||
|
||||
if isinstance(result, PaginatedResult):
|
||||
result.items = _validatePositions(result.items)
|
||||
return result
|
||||
return _validatePositions(result)
|
||||
|
||||
# ===== Trustee-specific Access Check =====
|
||||
|
||||
|
|
|
|||
|
|
@ -338,7 +338,7 @@ def get_organisations(
|
|||
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
||||
result = interface.getAllOrganisations(paginationParams)
|
||||
|
||||
if paginationParams:
|
||||
if paginationParams and hasattr(result, 'items'):
|
||||
return PaginatedResponse(
|
||||
items=result.items,
|
||||
pagination=PaginationMetadata(
|
||||
|
|
@ -350,7 +350,7 @@ def get_organisations(
|
|||
filters=paginationParams.filters if paginationParams else None
|
||||
)
|
||||
)
|
||||
return PaginatedResponse(items=result.items, pagination=None)
|
||||
return PaginatedResponse(items=result if isinstance(result, list) else result.items, pagination=None)
|
||||
|
||||
|
||||
@router.get("/{instanceId}/organisations/{orgId}", response_model=TrusteeOrganisation)
|
||||
|
|
@ -451,7 +451,7 @@ def get_roles(
|
|||
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
||||
result = interface.getAllRoles(paginationParams)
|
||||
|
||||
if paginationParams:
|
||||
if paginationParams and hasattr(result, 'items'):
|
||||
return PaginatedResponse(
|
||||
items=result.items,
|
||||
pagination=PaginationMetadata(
|
||||
|
|
@ -463,7 +463,7 @@ def get_roles(
|
|||
filters=paginationParams.filters if paginationParams else None
|
||||
)
|
||||
)
|
||||
return PaginatedResponse(items=result.items, pagination=None)
|
||||
return PaginatedResponse(items=result if isinstance(result, list) else result.items, pagination=None)
|
||||
|
||||
|
||||
@router.get("/{instanceId}/roles/{roleId}", response_model=TrusteeRole)
|
||||
|
|
@ -564,7 +564,7 @@ def get_all_access(
|
|||
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
||||
result = interface.getAllAccess(paginationParams)
|
||||
|
||||
if paginationParams:
|
||||
if paginationParams and hasattr(result, 'items'):
|
||||
return PaginatedResponse(
|
||||
items=result.items,
|
||||
pagination=PaginationMetadata(
|
||||
|
|
@ -576,7 +576,7 @@ def get_all_access(
|
|||
filters=paginationParams.filters if paginationParams else None
|
||||
)
|
||||
)
|
||||
return PaginatedResponse(items=result.items, pagination=None)
|
||||
return PaginatedResponse(items=result if isinstance(result, list) else result.items, pagination=None)
|
||||
|
||||
|
||||
@router.get("/{instanceId}/access/{accessId}", response_model=TrusteeAccess)
|
||||
|
|
@ -707,7 +707,7 @@ def get_contracts(
|
|||
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
||||
result = interface.getAllContracts(paginationParams)
|
||||
|
||||
if paginationParams:
|
||||
if paginationParams and hasattr(result, 'items'):
|
||||
return PaginatedResponse(
|
||||
items=result.items,
|
||||
pagination=PaginationMetadata(
|
||||
|
|
@ -719,7 +719,7 @@ def get_contracts(
|
|||
filters=paginationParams.filters if paginationParams else None
|
||||
)
|
||||
)
|
||||
return PaginatedResponse(items=result.items, pagination=None)
|
||||
return PaginatedResponse(items=result if isinstance(result, list) else result.items, pagination=None)
|
||||
|
||||
|
||||
@router.get("/{instanceId}/contracts/{contractId}", response_model=TrusteeContract)
|
||||
|
|
@ -835,7 +835,7 @@ def get_documents(
|
|||
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
||||
result = interface.getAllDocuments(paginationParams)
|
||||
|
||||
if paginationParams:
|
||||
if paginationParams and hasattr(result, 'items'):
|
||||
return PaginatedResponse(
|
||||
items=result.items,
|
||||
pagination=PaginationMetadata(
|
||||
|
|
@ -847,7 +847,59 @@ def get_documents(
|
|||
filters=paginationParams.filters if paginationParams else None
|
||||
)
|
||||
)
|
||||
return PaginatedResponse(items=result.items, pagination=None)
|
||||
return PaginatedResponse(items=result if isinstance(result, list) else result.items, pagination=None)
|
||||
|
||||
|
||||
@router.get("/{instanceId}/documents/filter-values")
|
||||
@limiter.limit("60/minute")
|
||||
def get_document_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 trustee documents."""
|
||||
mandateId = _validateInstanceAccess(instanceId, context)
|
||||
try:
|
||||
from modules.interfaces.interfaceRbac import getDistinctColumnValuesWithRBAC
|
||||
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
||||
|
||||
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 = getDistinctColumnValuesWithRBAC(
|
||||
connector=interface.db,
|
||||
modelClass=TrusteeDocument,
|
||||
column=column,
|
||||
currentUser=interface.currentUser,
|
||||
pagination=crossFilterPagination,
|
||||
recordFilter=None,
|
||||
mandateId=interface.mandateId,
|
||||
featureInstanceId=interface.featureInstanceId,
|
||||
featureCode=interface.FEATURE_CODE
|
||||
)
|
||||
return sorted(values, key=lambda v: str(v).lower())
|
||||
except Exception:
|
||||
from modules.routes.routeDataUsers import _handleFilterValuesRequest
|
||||
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 _handleFilterValuesRequest(items, column, pagination)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting filter values for trustee documents: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{instanceId}/documents/{documentId}", response_model=TrusteeDocument)
|
||||
|
|
@ -1039,7 +1091,7 @@ def get_positions(
|
|||
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
||||
result = interface.getAllPositions(paginationParams)
|
||||
|
||||
if paginationParams:
|
||||
if paginationParams and hasattr(result, 'items'):
|
||||
return PaginatedResponse(
|
||||
items=result.items,
|
||||
pagination=PaginationMetadata(
|
||||
|
|
@ -1051,7 +1103,59 @@ def get_positions(
|
|||
filters=paginationParams.filters if paginationParams else None
|
||||
)
|
||||
)
|
||||
return PaginatedResponse(items=result.items, pagination=None)
|
||||
return PaginatedResponse(items=result if isinstance(result, list) else result.items, pagination=None)
|
||||
|
||||
|
||||
@router.get("/{instanceId}/positions/filter-values")
|
||||
@limiter.limit("60/minute")
|
||||
def get_position_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 trustee positions."""
|
||||
mandateId = _validateInstanceAccess(instanceId, context)
|
||||
try:
|
||||
from modules.interfaces.interfaceRbac import getDistinctColumnValuesWithRBAC
|
||||
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
||||
|
||||
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 = getDistinctColumnValuesWithRBAC(
|
||||
connector=interface.db,
|
||||
modelClass=TrusteePosition,
|
||||
column=column,
|
||||
currentUser=interface.currentUser,
|
||||
pagination=crossFilterPagination,
|
||||
recordFilter=None,
|
||||
mandateId=interface.mandateId,
|
||||
featureInstanceId=interface.featureInstanceId,
|
||||
featureCode=interface.FEATURE_CODE
|
||||
)
|
||||
return sorted(values, key=lambda v: str(v).lower())
|
||||
except Exception:
|
||||
from modules.routes.routeDataUsers import _handleFilterValuesRequest
|
||||
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 _handleFilterValuesRequest(items, column, pagination)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting filter values for trustee positions: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{instanceId}/positions/{positionId}", response_model=TrusteePosition)
|
||||
|
|
|
|||
|
|
@ -423,7 +423,7 @@ class AppObjects:
|
|||
|
||||
else:
|
||||
# Unknown operator - default to equals
|
||||
if record_value != filter_val:
|
||||
if str(record_value).lower() != str(filter_val).lower():
|
||||
matches = False
|
||||
break
|
||||
|
||||
|
|
@ -512,53 +512,34 @@ class AppObjects:
|
|||
If pagination is None: List[User]
|
||||
If pagination is provided: PaginatedResult with items and metadata
|
||||
"""
|
||||
# Get user IDs via UserMandate junction table (UserInDB has no mandateId column)
|
||||
userMandates = self.db.getRecordset(UserMandate, recordFilter={"mandateId": mandateId})
|
||||
userIds = [um.get("userId") for um in userMandates if um.get("userId")]
|
||||
|
||||
# Fetch each user by ID
|
||||
filteredUsers = []
|
||||
for userId in userIds:
|
||||
userRecords = self.db.getRecordset(UserInDB, recordFilter={"id": userId})
|
||||
if userRecords:
|
||||
cleanedUser = {k: v for k, v in userRecords[0].items() if not k.startswith("_")}
|
||||
if cleanedUser.get("roleLabels") is None:
|
||||
cleanedUser["roleLabels"] = []
|
||||
filteredUsers.append(cleanedUser)
|
||||
|
||||
# If no pagination requested, return all items
|
||||
if not userIds:
|
||||
if pagination is None:
|
||||
return []
|
||||
return PaginatedResult(items=[], totalItems=0, totalPages=0)
|
||||
|
||||
result = self.db.getRecordsetPaginated(
|
||||
UserInDB,
|
||||
pagination=pagination,
|
||||
recordFilter={"id": userIds}
|
||||
)
|
||||
|
||||
items = []
|
||||
for record in result["items"]:
|
||||
cleanedUser = {k: v for k, v in record.items() if not k.startswith("_")}
|
||||
if cleanedUser.get("roleLabels") is None:
|
||||
cleanedUser["roleLabels"] = []
|
||||
items.append(User(**cleanedUser))
|
||||
|
||||
if pagination is None:
|
||||
return [User(**user) for user in filteredUsers]
|
||||
|
||||
# Apply filtering (if filters provided)
|
||||
if pagination.filters:
|
||||
filteredUsers = self._applyFilters(filteredUsers, pagination.filters)
|
||||
|
||||
# Apply sorting (in order of sortFields)
|
||||
if pagination.sort:
|
||||
filteredUsers = self._applySorting(filteredUsers, pagination.sort)
|
||||
|
||||
# Count total items after filters
|
||||
totalItems = len(filteredUsers)
|
||||
totalPages = math.ceil(totalItems / pagination.pageSize) if totalItems > 0 else 0
|
||||
|
||||
# Apply pagination (skip/limit)
|
||||
startIdx = (pagination.page - 1) * pagination.pageSize
|
||||
endIdx = startIdx + pagination.pageSize
|
||||
pagedUsers = filteredUsers[startIdx:endIdx]
|
||||
|
||||
# Ensure roleLabels is always a list for paginated results too
|
||||
for user in pagedUsers:
|
||||
if user.get("roleLabels") is None:
|
||||
user["roleLabels"] = []
|
||||
|
||||
# Convert to model objects
|
||||
items = [User(**user) for user in pagedUsers]
|
||||
|
||||
return items
|
||||
|
||||
return PaginatedResult(
|
||||
items=items,
|
||||
totalItems=totalItems,
|
||||
totalPages=totalPages
|
||||
totalItems=result["totalItems"],
|
||||
totalPages=result["totalPages"]
|
||||
)
|
||||
|
||||
def getUserByUsername(self, username: str) -> Optional[User]:
|
||||
|
|
@ -2312,30 +2293,43 @@ class AppObjects:
|
|||
# Additional Helper Methods
|
||||
# ============================================
|
||||
|
||||
def getAllUsers(self) -> List[User]:
|
||||
def getAllUsers(self, pagination: Optional[PaginationParams] = None) -> Union[List[User], PaginatedResult]:
|
||||
"""
|
||||
Get all users (for SysAdmin only).
|
||||
|
||||
Args:
|
||||
pagination: Optional pagination parameters. If None, returns all items.
|
||||
|
||||
Returns:
|
||||
List of User objects (without sensitive fields)
|
||||
If pagination is None: List[User] (without sensitive fields)
|
||||
If pagination is provided: PaginatedResult with items and metadata
|
||||
"""
|
||||
try:
|
||||
records = self.db.getRecordset(UserInDB)
|
||||
result = []
|
||||
for record in records:
|
||||
# Filter out sensitive and internal fields
|
||||
result = self.db.getRecordsetPaginated(UserInDB, pagination=pagination)
|
||||
|
||||
items = []
|
||||
for record in result["items"]:
|
||||
cleanedRecord = {
|
||||
k: v for k, v in record.items()
|
||||
k: v for k, v in record.items()
|
||||
if not k.startswith("_") and k not in ["hashedPassword", "resetToken", "resetTokenExpires"]
|
||||
}
|
||||
# Ensure roleLabels is a list
|
||||
if cleanedRecord.get("roleLabels") is None:
|
||||
cleanedRecord["roleLabels"] = []
|
||||
result.append(User(**cleanedRecord))
|
||||
return result
|
||||
items.append(User(**cleanedRecord))
|
||||
|
||||
if pagination is None:
|
||||
return items
|
||||
|
||||
return PaginatedResult(
|
||||
items=items,
|
||||
totalItems=result["totalItems"],
|
||||
totalPages=result["totalPages"]
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting all users: {e}")
|
||||
return []
|
||||
if pagination is None:
|
||||
return []
|
||||
return PaginatedResult(items=[], totalItems=0, totalPages=0)
|
||||
|
||||
def getUserMandateById(self, userMandateId: str) -> Optional[UserMandate]:
|
||||
"""
|
||||
|
|
@ -3321,50 +3315,26 @@ class AppObjects:
|
|||
If pagination is provided: PaginatedResult with items and metadata
|
||||
"""
|
||||
try:
|
||||
# Get all roles from database
|
||||
roles = self.db.getRecordset(Role)
|
||||
|
||||
# Filter out database-specific fields
|
||||
filteredRoles = []
|
||||
for role in roles:
|
||||
cleanedRole = {k: v for k, v in role.items() if not k.startswith("_")}
|
||||
filteredRoles.append(cleanedRole)
|
||||
|
||||
# If no pagination requested, return all items
|
||||
result = self.db.getRecordsetPaginated(Role, pagination=pagination)
|
||||
|
||||
items = []
|
||||
for record in result["items"]:
|
||||
cleanedRole = {k: v for k, v in record.items() if not k.startswith("_")}
|
||||
items.append(Role(**cleanedRole))
|
||||
|
||||
if pagination is None:
|
||||
return [Role(**role) for role in filteredRoles]
|
||||
|
||||
# Apply filtering (if filters provided)
|
||||
if pagination.filters:
|
||||
filteredRoles = self._applyFilters(filteredRoles, pagination.filters)
|
||||
|
||||
# Apply sorting (in order of sortFields)
|
||||
if pagination.sort:
|
||||
filteredRoles = self._applySorting(filteredRoles, pagination.sort)
|
||||
|
||||
# Count total items after filters
|
||||
totalItems = len(filteredRoles)
|
||||
totalPages = math.ceil(totalItems / pagination.pageSize) if totalItems > 0 else 0
|
||||
|
||||
# Apply pagination (skip/limit)
|
||||
startIdx = (pagination.page - 1) * pagination.pageSize
|
||||
endIdx = startIdx + pagination.pageSize
|
||||
pagedRoles = filteredRoles[startIdx:endIdx]
|
||||
|
||||
# Convert to model objects
|
||||
items = [Role(**role) for role in pagedRoles]
|
||||
|
||||
return items
|
||||
|
||||
return PaginatedResult(
|
||||
items=items,
|
||||
totalItems=totalItems,
|
||||
totalPages=totalPages
|
||||
totalItems=result["totalItems"],
|
||||
totalPages=result["totalPages"]
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting all roles: {str(e)}")
|
||||
if pagination is None:
|
||||
return []
|
||||
else:
|
||||
return PaginatedResult(items=[], totalItems=0, totalPages=0)
|
||||
return PaginatedResult(items=[], totalItems=0, totalPages=0)
|
||||
|
||||
def countRoleAssignments(self) -> Dict[str, int]:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ All billing data is stored in the poweron_billing database.
|
|||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, Any, List, Optional
|
||||
from typing import Dict, Any, List, Optional, Union
|
||||
from datetime import date, datetime, timedelta
|
||||
import uuid
|
||||
|
||||
|
|
@ -17,6 +17,7 @@ from modules.shared.configuration import APP_CONFIG
|
|||
from modules.shared.timeUtils import getUtcTimestamp
|
||||
from modules.datamodels.datamodelUam import User, Mandate
|
||||
from modules.datamodels.datamodelMembership import UserMandate
|
||||
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResult
|
||||
from modules.datamodels.datamodelBilling import (
|
||||
BillingAccount,
|
||||
BillingTransaction,
|
||||
|
|
@ -633,26 +634,43 @@ class BillingObjects:
|
|||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
startDate: date = None,
|
||||
endDate: date = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
endDate: date = None,
|
||||
pagination: PaginationParams = None
|
||||
) -> Union[List[Dict[str, Any]], PaginatedResult]:
|
||||
"""
|
||||
Get transactions for an account.
|
||||
|
||||
When pagination is provided, uses database-level pagination and returns
|
||||
PaginatedResult. Otherwise falls back to in-memory filtering/sorting/slicing.
|
||||
|
||||
Args:
|
||||
accountId: Account ID
|
||||
limit: Maximum number of results
|
||||
offset: Offset for pagination
|
||||
startDate: Filter by start date
|
||||
endDate: Filter by end date
|
||||
limit: Maximum number of results (legacy path)
|
||||
offset: Offset for pagination (legacy path)
|
||||
startDate: Filter by start date (legacy path)
|
||||
endDate: Filter by end date (legacy path)
|
||||
pagination: PaginationParams for DB-level pagination
|
||||
|
||||
Returns:
|
||||
List of transaction dicts
|
||||
PaginatedResult when pagination is provided, List of dicts otherwise
|
||||
"""
|
||||
try:
|
||||
if pagination:
|
||||
recordFilter = {"accountId": accountId}
|
||||
result = self.db.getRecordsetPaginated(
|
||||
BillingTransaction,
|
||||
pagination=pagination,
|
||||
recordFilter=recordFilter
|
||||
)
|
||||
return PaginatedResult(
|
||||
items=result["items"],
|
||||
totalItems=result["totalItems"],
|
||||
totalPages=result["totalPages"]
|
||||
)
|
||||
|
||||
filterDict = {"accountId": accountId}
|
||||
results = self.db.getRecordset(BillingTransaction, recordFilter=filterDict)
|
||||
|
||||
# Apply date filters if provided
|
||||
if startDate or endDate:
|
||||
filtered = []
|
||||
for t in results:
|
||||
|
|
@ -666,35 +684,61 @@ class BillingObjects:
|
|||
filtered.append(t)
|
||||
results = filtered
|
||||
|
||||
# Sort by creation date descending
|
||||
results.sort(key=lambda x: x.get("_createdAt", ""), reverse=True)
|
||||
|
||||
# Apply pagination
|
||||
return results[offset:offset + limit]
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting transactions: {e}")
|
||||
if pagination:
|
||||
return PaginatedResult(items=[], totalItems=0, totalPages=0)
|
||||
return []
|
||||
|
||||
def getTransactionsByMandate(self, mandateId: str, limit: int = 100) -> List[Dict[str, Any]]:
|
||||
def getTransactionsByMandate(
|
||||
self,
|
||||
mandateId: str,
|
||||
limit: int = 100,
|
||||
pagination: PaginationParams = None
|
||||
) -> Union[List[Dict[str, Any]], PaginatedResult]:
|
||||
"""
|
||||
Get all transactions for a mandate (across all accounts).
|
||||
|
||||
When pagination is provided, collects all accountIds for the mandate and
|
||||
issues a single DB query with SQL-level filtering, sorting, and pagination.
|
||||
Otherwise falls back to per-account querying and in-memory merging.
|
||||
|
||||
Args:
|
||||
mandateId: Mandate ID
|
||||
limit: Maximum number of results
|
||||
limit: Maximum number of results (legacy path)
|
||||
pagination: PaginationParams for DB-level pagination
|
||||
|
||||
Returns:
|
||||
List of transaction dicts
|
||||
PaginatedResult when pagination is provided, List of dicts otherwise
|
||||
"""
|
||||
# Get all accounts for mandate
|
||||
accounts = self.db.getRecordset(BillingAccount, recordFilter={"mandateId": mandateId})
|
||||
|
||||
accountIds = [acc["id"] for acc in accounts if acc.get("id")]
|
||||
|
||||
if not accountIds:
|
||||
if pagination:
|
||||
return PaginatedResult(items=[], totalItems=0, totalPages=0)
|
||||
return []
|
||||
|
||||
if pagination:
|
||||
result = self.db.getRecordsetPaginated(
|
||||
BillingTransaction,
|
||||
pagination=pagination,
|
||||
recordFilter={"accountId": accountIds}
|
||||
)
|
||||
return PaginatedResult(
|
||||
items=result["items"],
|
||||
totalItems=result["totalItems"],
|
||||
totalPages=result["totalPages"]
|
||||
)
|
||||
|
||||
allTransactions = []
|
||||
for account in accounts:
|
||||
transactions = self.getTransactions(account["id"], limit=limit)
|
||||
allTransactions.extend(transactions)
|
||||
|
||||
# Sort by creation date descending and limit
|
||||
allTransactions.sort(key=lambda x: x.get("_createdAt", ""), reverse=True)
|
||||
return allTransactions[:limit]
|
||||
|
||||
|
|
|
|||
|
|
@ -450,7 +450,7 @@ class ChatObjects:
|
|||
|
||||
# Handle simple value (equals operator)
|
||||
if not isinstance(filter_value, dict):
|
||||
if record_value != filter_value:
|
||||
if str(record_value).lower() != str(filter_value).lower():
|
||||
matches = False
|
||||
break
|
||||
continue
|
||||
|
|
@ -460,7 +460,7 @@ class ChatObjects:
|
|||
filter_val = filter_value.get("value")
|
||||
|
||||
if operator in ["equals", "eq"]:
|
||||
if record_value != filter_val:
|
||||
if str(record_value).lower() != str(filter_val).lower():
|
||||
matches = False
|
||||
break
|
||||
|
||||
|
|
@ -545,7 +545,7 @@ class ChatObjects:
|
|||
|
||||
else:
|
||||
# Unknown operator - default to equals
|
||||
if record_value != filter_val:
|
||||
if str(record_value).lower() != str(filter_val).lower():
|
||||
matches = False
|
||||
break
|
||||
|
||||
|
|
|
|||
|
|
@ -390,7 +390,7 @@ class ComponentObjects:
|
|||
|
||||
# Handle simple value (equals operator)
|
||||
if not isinstance(filter_value, dict):
|
||||
if record_value != filter_value:
|
||||
if str(record_value).lower() != str(filter_value).lower():
|
||||
matches = False
|
||||
break
|
||||
continue
|
||||
|
|
@ -400,7 +400,7 @@ class ComponentObjects:
|
|||
filter_val = filter_value.get("value")
|
||||
|
||||
if operator in ["equals", "eq"]:
|
||||
if record_value != filter_val:
|
||||
if str(record_value).lower() != str(filter_val).lower():
|
||||
matches = False
|
||||
break
|
||||
|
||||
|
|
@ -650,6 +650,10 @@ class ComponentObjects:
|
|||
- Regular user: sees own prompts + system prompts (isSystem=True), can only CRUD own
|
||||
- Row-level _permissions control edit/delete buttons in the UI
|
||||
|
||||
NOTE: Cannot use db.getRecordsetPaginated() because visibility rules
|
||||
(_getPromptsForUser: own + system for regular, all for SysAdmin) and
|
||||
per-row _permissions enrichment require loading all records first.
|
||||
|
||||
Args:
|
||||
pagination: Optional pagination parameters. If None, returns all items.
|
||||
|
||||
|
|
@ -914,7 +918,7 @@ class ComponentObjects:
|
|||
"""
|
||||
Returns files owned by the current user (user-scoped, not RBAC-based).
|
||||
Every user (including SysAdmin) only sees their own files.
|
||||
Supports optional pagination, sorting, and filtering.
|
||||
Supports optional pagination, sorting, and filtering via database-level queries.
|
||||
|
||||
Args:
|
||||
pagination: Optional pagination parameters. If None, returns all items.
|
||||
|
|
@ -923,24 +927,21 @@ class ComponentObjects:
|
|||
If pagination is None: List[FileItem]
|
||||
If pagination is provided: PaginatedResult with items and metadata
|
||||
"""
|
||||
# Files are always user-scoped: filter by _createdBy (bypasses RBAC SysAdmin override)
|
||||
filteredFiles = self._getFilesByCurrentUser()
|
||||
|
||||
# Convert database records to FileItem instances (extra='allow' preserves system fields like _createdBy)
|
||||
def convertFileItems(files):
|
||||
# User-scoping filter: every user only sees their own files (bypasses RBAC SysAdmin override)
|
||||
recordFilter = {"_createdBy": self.userId}
|
||||
|
||||
def _convertFileItems(files):
|
||||
fileItems = []
|
||||
for file in files:
|
||||
try:
|
||||
# Ensure proper values, use defaults for invalid data
|
||||
creationDate = file.get("creationDate")
|
||||
if creationDate is None or not isinstance(creationDate, (int, float)) or creationDate <= 0:
|
||||
file["creationDate"] = getUtcTimestamp()
|
||||
|
||||
fileName = file.get("fileName")
|
||||
if not fileName or fileName == "None":
|
||||
continue # Skip records with invalid fileName
|
||||
continue
|
||||
|
||||
# Use **file to pass all fields including system fields (_createdBy, etc.)
|
||||
fileItem = FileItem(**file)
|
||||
fileItems.append(fileItem)
|
||||
except Exception as e:
|
||||
|
|
@ -948,34 +949,23 @@ class ComponentObjects:
|
|||
continue
|
||||
return fileItems
|
||||
|
||||
# If no pagination requested, return all items
|
||||
if pagination is None:
|
||||
return convertFileItems(filteredFiles)
|
||||
allFiles = self._getFilesByCurrentUser()
|
||||
return _convertFileItems(allFiles)
|
||||
|
||||
# Apply filtering (if filters provided)
|
||||
if pagination.filters:
|
||||
filteredFiles = self._applyFilters(filteredFiles, pagination.filters)
|
||||
# Database-level pagination: filtering, sorting, and LIMIT/OFFSET happen in SQL
|
||||
result = self.db.getRecordsetPaginated(
|
||||
FileItem,
|
||||
pagination=pagination,
|
||||
recordFilter=recordFilter
|
||||
)
|
||||
|
||||
# Apply sorting (in order of sortFields)
|
||||
if pagination.sort:
|
||||
filteredFiles = self._applySorting(filteredFiles, pagination.sort)
|
||||
|
||||
# Count total items after filters
|
||||
totalItems = len(filteredFiles)
|
||||
totalPages = math.ceil(totalItems / pagination.pageSize) if totalItems > 0 else 0
|
||||
|
||||
# Apply pagination (skip/limit)
|
||||
startIdx = (pagination.page - 1) * pagination.pageSize
|
||||
endIdx = startIdx + pagination.pageSize
|
||||
pagedFiles = filteredFiles[startIdx:endIdx]
|
||||
|
||||
# Convert to model objects (extra='allow' on FileItem preserves system fields)
|
||||
items = convertFileItems(pagedFiles)
|
||||
items = _convertFileItems(result["items"])
|
||||
|
||||
return PaginatedResult(
|
||||
items=items,
|
||||
totalItems=totalItems,
|
||||
totalPages=totalPages
|
||||
totalItems=result["totalItems"],
|
||||
totalPages=result["totalPages"]
|
||||
)
|
||||
|
||||
def getFile(self, fileId: str) -> Optional[FileItem]:
|
||||
|
|
|
|||
|
|
@ -23,10 +23,12 @@ GROUP-Berechtigung:
|
|||
|
||||
import logging
|
||||
import json
|
||||
from typing import List, Dict, Any, Optional, Type
|
||||
import math
|
||||
from typing import List, Dict, Any, Optional, Type, Union
|
||||
from pydantic import BaseModel
|
||||
from modules.datamodels.datamodelRbac import AccessRuleContext
|
||||
from modules.datamodels.datamodelUam import User, UserPermissions, AccessLevel
|
||||
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResult
|
||||
from modules.security.rbac import RbacClass
|
||||
from modules.security.rootAccess import getRootDbAppConnector
|
||||
|
||||
|
|
@ -297,6 +299,309 @@ def getRecordsetWithRBAC(
|
|||
return []
|
||||
|
||||
|
||||
def getRecordsetPaginatedWithRBAC(
|
||||
connector,
|
||||
modelClass: Type[BaseModel],
|
||||
currentUser: User,
|
||||
pagination: Optional[PaginationParams] = None,
|
||||
recordFilter: Dict[str, Any] = None,
|
||||
mandateId: Optional[str] = None,
|
||||
featureInstanceId: Optional[str] = None,
|
||||
enrichPermissions: bool = False,
|
||||
featureCode: Optional[str] = None,
|
||||
) -> Union[List[Dict[str, Any]], PaginatedResult]:
|
||||
"""
|
||||
Get records with RBAC filtering and SQL-level pagination.
|
||||
When pagination is None, returns a plain list (backward compatible).
|
||||
When pagination is provided, returns PaginatedResult with COUNT + LIMIT/OFFSET at SQL level.
|
||||
"""
|
||||
table = modelClass.__name__
|
||||
objectKey = buildDataObjectKey(table, featureCode)
|
||||
effectiveMandateId = mandateId
|
||||
|
||||
try:
|
||||
if not connector._ensureTableExists(modelClass):
|
||||
return PaginatedResult(items=[], totalItems=0, totalPages=0) if pagination else []
|
||||
|
||||
dbApp = getRootDbAppConnector()
|
||||
rbacInstance = RbacClass(connector, dbApp=dbApp)
|
||||
permissions = rbacInstance.getUserPermissions(
|
||||
currentUser,
|
||||
AccessRuleContext.DATA,
|
||||
objectKey,
|
||||
mandateId=effectiveMandateId,
|
||||
featureInstanceId=featureInstanceId
|
||||
)
|
||||
|
||||
if not permissions.view:
|
||||
return PaginatedResult(items=[], totalItems=0, totalPages=0) if pagination else []
|
||||
|
||||
whereConditions = []
|
||||
whereValues = []
|
||||
|
||||
featureInstanceIdForQuery = featureInstanceId
|
||||
if featureInstanceId and hasattr(modelClass, 'model_fields') and "featureInstanceId" not in modelClass.model_fields:
|
||||
featureInstanceIdForQuery = None
|
||||
|
||||
rbacWhereClause = buildRbacWhereClause(
|
||||
permissions, currentUser, table, connector,
|
||||
mandateId=effectiveMandateId,
|
||||
featureInstanceId=featureInstanceIdForQuery
|
||||
)
|
||||
if rbacWhereClause:
|
||||
whereConditions.append(rbacWhereClause["condition"])
|
||||
whereValues.extend(rbacWhereClause["values"])
|
||||
|
||||
if recordFilter:
|
||||
for field, value in recordFilter.items():
|
||||
if isinstance(value, (list, tuple)):
|
||||
if len(value) == 0:
|
||||
whereConditions.append("1 = 0")
|
||||
else:
|
||||
whereConditions.append(f'"{field}" = ANY(%s)')
|
||||
whereValues.append(list(value))
|
||||
elif value is None:
|
||||
whereConditions.append(f'"{field}" IS NULL')
|
||||
else:
|
||||
whereConditions.append(f'"{field}" = %s')
|
||||
whereValues.append(value)
|
||||
|
||||
if pagination and pagination.filters:
|
||||
from modules.connectors.connectorDbPostgre import _get_model_fields
|
||||
fields = _get_model_fields(modelClass)
|
||||
validColumns = set(fields.keys())
|
||||
for key, val in pagination.filters.items():
|
||||
if key == "search" and isinstance(val, str) and val.strip():
|
||||
term = f"%{val.strip()}%"
|
||||
textCols = [c for c, t in fields.items() if t == "TEXT"]
|
||||
if textCols:
|
||||
orParts = [f'COALESCE("{c}"::TEXT, \'\') ILIKE %s' for c in textCols]
|
||||
whereConditions.append(f"({' OR '.join(orParts)})")
|
||||
whereValues.extend([term] * len(textCols))
|
||||
continue
|
||||
if key not in validColumns:
|
||||
continue
|
||||
if isinstance(val, dict):
|
||||
op = val.get("operator", "equals")
|
||||
v = val.get("value", "")
|
||||
if op in ("equals", "eq"):
|
||||
whereConditions.append(f'"{key}"::TEXT = %s')
|
||||
whereValues.append(str(v))
|
||||
elif op == "contains":
|
||||
whereConditions.append(f'"{key}"::TEXT ILIKE %s')
|
||||
whereValues.append(f"%{v}%")
|
||||
elif op == "startsWith":
|
||||
whereConditions.append(f'"{key}"::TEXT ILIKE %s')
|
||||
whereValues.append(f"{v}%")
|
||||
elif op == "endsWith":
|
||||
whereConditions.append(f'"{key}"::TEXT ILIKE %s')
|
||||
whereValues.append(f"%{v}")
|
||||
elif op in ("gt", "gte", "lt", "lte"):
|
||||
sqlOp = {"gt": ">", "gte": ">=", "lt": "<", "lte": "<="}[op]
|
||||
whereConditions.append(f'"{key}"::TEXT {sqlOp} %s')
|
||||
whereValues.append(str(v))
|
||||
elif op == "between":
|
||||
fromVal = v.get("from", "") if isinstance(v, dict) else ""
|
||||
toVal = v.get("to", "") if isinstance(v, dict) else ""
|
||||
if fromVal and toVal:
|
||||
whereConditions.append(f'"{key}"::TEXT >= %s AND "{key}"::TEXT <= %s')
|
||||
whereValues.extend([str(fromVal), str(toVal)])
|
||||
elif fromVal:
|
||||
whereConditions.append(f'"{key}"::TEXT >= %s')
|
||||
whereValues.append(str(fromVal))
|
||||
elif toVal:
|
||||
whereConditions.append(f'"{key}"::TEXT <= %s')
|
||||
whereValues.append(str(toVal))
|
||||
else:
|
||||
whereConditions.append(f'"{key}"::TEXT ILIKE %s')
|
||||
whereValues.append(str(val))
|
||||
|
||||
whereClause = " WHERE " + " AND ".join(whereConditions) if whereConditions else ""
|
||||
countValues = list(whereValues)
|
||||
|
||||
orderParts: List[str] = []
|
||||
if pagination and pagination.sort:
|
||||
from modules.connectors.connectorDbPostgre import _get_model_fields
|
||||
validColumns = set(_get_model_fields(modelClass).keys())
|
||||
for sf in pagination.sort:
|
||||
if sf.field in validColumns:
|
||||
direction = "DESC" if sf.direction.lower() == "desc" else "ASC"
|
||||
orderParts.append(f'"{sf.field}" {direction}')
|
||||
if not orderParts:
|
||||
orderParts.append('"id"')
|
||||
orderByClause = " ORDER BY " + ", ".join(orderParts)
|
||||
|
||||
limitClause = ""
|
||||
if pagination:
|
||||
offset = (pagination.page - 1) * pagination.pageSize
|
||||
limitClause = f" LIMIT {pagination.pageSize} OFFSET {offset}"
|
||||
|
||||
with connector.connection.cursor() as cursor:
|
||||
countSql = f'SELECT COUNT(*) FROM "{table}"{whereClause}'
|
||||
cursor.execute(countSql, countValues)
|
||||
totalItems = cursor.fetchone()["count"]
|
||||
|
||||
dataSql = f'SELECT * FROM "{table}"{whereClause}{orderByClause}{limitClause}'
|
||||
cursor.execute(dataSql, whereValues)
|
||||
records = [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
from modules.connectors.connectorDbPostgre import _get_model_fields, _parseRecordFields
|
||||
fields = _get_model_fields(modelClass)
|
||||
for record in records:
|
||||
_parseRecordFields(record, fields, f"table {table}")
|
||||
for fieldName, fieldType in fields.items():
|
||||
if fieldType == "JSONB" and fieldName in record and record[fieldName] is None:
|
||||
modelFields = modelClass.model_fields
|
||||
fieldInfo = modelFields.get(fieldName)
|
||||
if fieldInfo:
|
||||
fieldAnnotation = fieldInfo.annotation
|
||||
if (fieldAnnotation == list or
|
||||
(hasattr(fieldAnnotation, "__origin__") and fieldAnnotation.__origin__ is list)):
|
||||
record[fieldName] = []
|
||||
elif (fieldAnnotation == dict or
|
||||
(hasattr(fieldAnnotation, "__origin__") and fieldAnnotation.__origin__ is dict)):
|
||||
record[fieldName] = {}
|
||||
|
||||
if enrichPermissions:
|
||||
records = _enrichRecordsWithPermissions(records, permissions, currentUser)
|
||||
|
||||
if pagination:
|
||||
pageSize = pagination.pageSize
|
||||
totalPages = math.ceil(totalItems / pageSize) if totalItems > 0 else 0
|
||||
return PaginatedResult(items=records, totalItems=totalItems, totalPages=totalPages)
|
||||
|
||||
return records
|
||||
except Exception as e:
|
||||
logger.error(f"Error in getRecordsetPaginatedWithRBAC for table {table}: {e}")
|
||||
return PaginatedResult(items=[], totalItems=0, totalPages=0) if pagination else []
|
||||
|
||||
|
||||
def getDistinctColumnValuesWithRBAC(
|
||||
connector,
|
||||
modelClass: Type[BaseModel],
|
||||
currentUser: User,
|
||||
column: str,
|
||||
pagination: Optional[PaginationParams] = None,
|
||||
recordFilter: Dict[str, Any] = None,
|
||||
mandateId: Optional[str] = None,
|
||||
featureInstanceId: Optional[str] = None,
|
||||
featureCode: Optional[str] = None,
|
||||
) -> List[str]:
|
||||
"""
|
||||
Get sorted distinct values for a column with RBAC filtering at SQL level.
|
||||
Cross-filtering: removes the requested column from active filters.
|
||||
"""
|
||||
import copy
|
||||
table = modelClass.__name__
|
||||
objectKey = buildDataObjectKey(table, featureCode)
|
||||
|
||||
try:
|
||||
if not connector._ensureTableExists(modelClass):
|
||||
return []
|
||||
|
||||
from modules.connectors.connectorDbPostgre import _get_model_fields
|
||||
fields = _get_model_fields(modelClass)
|
||||
if column not in fields:
|
||||
return []
|
||||
|
||||
dbApp = getRootDbAppConnector()
|
||||
rbacInstance = RbacClass(connector, dbApp=dbApp)
|
||||
permissions = rbacInstance.getUserPermissions(
|
||||
currentUser, AccessRuleContext.DATA, objectKey,
|
||||
mandateId=mandateId, featureInstanceId=featureInstanceId
|
||||
)
|
||||
if not permissions.view:
|
||||
return []
|
||||
|
||||
whereConditions = []
|
||||
whereValues = []
|
||||
|
||||
featureInstanceIdForQuery = featureInstanceId
|
||||
if featureInstanceId and hasattr(modelClass, 'model_fields') and "featureInstanceId" not in modelClass.model_fields:
|
||||
featureInstanceIdForQuery = None
|
||||
|
||||
rbacWhereClause = buildRbacWhereClause(
|
||||
permissions, currentUser, table, connector,
|
||||
mandateId=mandateId, featureInstanceId=featureInstanceIdForQuery
|
||||
)
|
||||
if rbacWhereClause:
|
||||
whereConditions.append(rbacWhereClause["condition"])
|
||||
whereValues.extend(rbacWhereClause["values"])
|
||||
|
||||
if recordFilter:
|
||||
for field, value in recordFilter.items():
|
||||
if isinstance(value, (list, tuple)):
|
||||
if not value:
|
||||
whereConditions.append("1 = 0")
|
||||
else:
|
||||
whereConditions.append(f'"{field}" = ANY(%s)')
|
||||
whereValues.append(list(value))
|
||||
elif value is None:
|
||||
whereConditions.append(f'"{field}" IS NULL')
|
||||
else:
|
||||
whereConditions.append(f'"{field}" = %s')
|
||||
whereValues.append(value)
|
||||
|
||||
crossPagination = copy.deepcopy(pagination) if pagination else None
|
||||
if crossPagination and crossPagination.filters:
|
||||
crossPagination.filters.pop(column, None)
|
||||
validColumns = set(fields.keys())
|
||||
for key, val in crossPagination.filters.items():
|
||||
if key == "search" and isinstance(val, str) and val.strip():
|
||||
term = f"%{val.strip()}%"
|
||||
textCols = [c for c, t in fields.items() if t == "TEXT"]
|
||||
if textCols:
|
||||
orParts = [f'COALESCE("{c}"::TEXT, \'\') ILIKE %s' for c in textCols]
|
||||
whereConditions.append(f"({' OR '.join(orParts)})")
|
||||
whereValues.extend([term] * len(textCols))
|
||||
continue
|
||||
if key not in validColumns:
|
||||
continue
|
||||
if isinstance(val, dict):
|
||||
op = val.get("operator", "equals")
|
||||
v = val.get("value", "")
|
||||
if op in ("equals", "eq"):
|
||||
whereConditions.append(f'"{key}"::TEXT = %s')
|
||||
whereValues.append(str(v))
|
||||
elif op == "contains":
|
||||
whereConditions.append(f'"{key}"::TEXT ILIKE %s')
|
||||
whereValues.append(f"%{v}%")
|
||||
elif op == "between":
|
||||
fromVal = v.get("from", "") if isinstance(v, dict) else ""
|
||||
toVal = v.get("to", "") if isinstance(v, dict) else ""
|
||||
if fromVal and toVal:
|
||||
whereConditions.append(f'"{key}"::TEXT >= %s AND "{key}"::TEXT <= %s')
|
||||
whereValues.extend([str(fromVal), str(toVal)])
|
||||
elif fromVal:
|
||||
whereConditions.append(f'"{key}"::TEXT >= %s')
|
||||
whereValues.append(str(fromVal))
|
||||
elif toVal:
|
||||
whereConditions.append(f'"{key}"::TEXT <= %s')
|
||||
whereValues.append(str(toVal))
|
||||
else:
|
||||
whereConditions.append(f'"{key}"::TEXT ILIKE %s')
|
||||
whereValues.append(str(v) if isinstance(v, str) else str(val))
|
||||
else:
|
||||
whereConditions.append(f'"{key}"::TEXT ILIKE %s')
|
||||
whereValues.append(str(val))
|
||||
|
||||
whereClause = " WHERE " + " AND ".join(whereConditions) if whereConditions else ""
|
||||
notNullCond = f'"{column}" IS NOT NULL AND "{column}"::TEXT != \'\''
|
||||
if whereClause:
|
||||
whereClause += f" AND {notNullCond}"
|
||||
else:
|
||||
whereClause = f" WHERE {notNullCond}"
|
||||
|
||||
sql = f'SELECT DISTINCT "{column}"::TEXT AS val FROM "{table}"{whereClause} ORDER BY val'
|
||||
|
||||
with connector.connection.cursor() as cursor:
|
||||
cursor.execute(sql, whereValues)
|
||||
return [row["val"] for row in cursor.fetchall()]
|
||||
except Exception as e:
|
||||
logger.error(f"Error in getDistinctColumnValuesWithRBAC for {table}.{column}: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def buildRbacWhereClause(
|
||||
permissions: UserPermissions,
|
||||
currentUser: User,
|
||||
|
|
|
|||
|
|
@ -5,15 +5,19 @@ Admin automation events routes for the backend API.
|
|||
Sysadmin-only endpoints for viewing and controlling scheduler events.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Path, Request, Response
|
||||
from typing import List, Dict, Any
|
||||
from fastapi import APIRouter, HTTPException, Depends, Path, Request, Response, Query
|
||||
from typing import List, Dict, Any, Optional
|
||||
from fastapi import status
|
||||
import logging
|
||||
import json
|
||||
import math
|
||||
|
||||
# Import interfaces and models from feature containers
|
||||
import modules.features.automation.interfaceFeatureAutomation as interfaceAutomation
|
||||
from modules.auth import limiter, getRequestContext, requireSysAdminRole, RequestContext
|
||||
from modules.datamodels.datamodelUam import User
|
||||
from modules.datamodels.datamodelPagination import PaginationParams, PaginationMetadata, normalize_pagination_dict
|
||||
from modules.routes.routeDataUsers import _applyFiltersAndSort, _extractDistinctValues
|
||||
|
||||
# Configure logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -31,122 +35,176 @@ router = APIRouter(
|
|||
}
|
||||
)
|
||||
|
||||
def _buildEnrichedAutomationEvents(currentUser: User) -> List[Dict[str, Any]]:
|
||||
"""Build the full enriched automation events list."""
|
||||
from modules.shared.eventManagement import eventManager
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
from modules.features.automation.mainAutomation import getAutomationServices
|
||||
|
||||
if not eventManager.scheduler:
|
||||
return []
|
||||
|
||||
jobs = []
|
||||
for job in eventManager.scheduler.get_jobs():
|
||||
if job.id.startswith("automation."):
|
||||
automationId = job.id.replace("automation.", "")
|
||||
jobs.append({
|
||||
"eventId": job.id,
|
||||
"id": job.id,
|
||||
"automationId": automationId,
|
||||
"nextRunTime": str(job.next_run_time) if job.next_run_time else None,
|
||||
"trigger": str(job.trigger) if job.trigger else None,
|
||||
"name": "",
|
||||
"createdBy": "",
|
||||
"mandate": "",
|
||||
"featureInstance": ""
|
||||
})
|
||||
|
||||
if jobs:
|
||||
try:
|
||||
rootInterface = getRootInterface()
|
||||
eventUser = rootInterface.getUserByUsername("event")
|
||||
if eventUser:
|
||||
services = getAutomationServices(currentUser, mandateId=None, featureInstanceId=None)
|
||||
allAutomations = services.interfaceDbAutomation.getAllAutomationDefinitionsWithRBAC(eventUser)
|
||||
|
||||
automationLookup = {}
|
||||
for a in allAutomations:
|
||||
aId = a.get("id", "") if isinstance(a, dict) else getattr(a, "id", "")
|
||||
automationLookup[aId] = a
|
||||
|
||||
_userCache: Dict[str, str] = {}
|
||||
_mandateCache: Dict[str, str] = {}
|
||||
_featureCache: Dict[str, str] = {}
|
||||
|
||||
def _resolveUsername(userId):
|
||||
if not userId: return ""
|
||||
if userId not in _userCache:
|
||||
try:
|
||||
user = rootInterface.getUser(userId)
|
||||
_userCache[userId] = user.username if user else userId[:8]
|
||||
except Exception:
|
||||
_userCache[userId] = userId[:8]
|
||||
return _userCache[userId]
|
||||
|
||||
def _resolveMandateLabel(mandateId):
|
||||
if not mandateId: return ""
|
||||
if mandateId not in _mandateCache:
|
||||
try:
|
||||
mandate = rootInterface.getMandate(mandateId)
|
||||
_mandateCache[mandateId] = getattr(mandate, "label", None) or mandateId[:8]
|
||||
except Exception:
|
||||
_mandateCache[mandateId] = mandateId[:8]
|
||||
return _mandateCache[mandateId]
|
||||
|
||||
def _resolveFeatureLabel(featureInstanceId):
|
||||
if not featureInstanceId: return ""
|
||||
if featureInstanceId not in _featureCache:
|
||||
try:
|
||||
instance = rootInterface.getFeatureInstance(featureInstanceId)
|
||||
_featureCache[featureInstanceId] = getattr(instance, "label", None) or getattr(instance, "featureCode", None) or featureInstanceId[:8]
|
||||
except Exception:
|
||||
_featureCache[featureInstanceId] = featureInstanceId[:8]
|
||||
return _featureCache[featureInstanceId]
|
||||
|
||||
for job in jobs:
|
||||
automation = automationLookup.get(job["automationId"])
|
||||
if automation:
|
||||
if isinstance(automation, dict):
|
||||
job["name"] = automation.get("label", "")
|
||||
job["createdBy"] = _resolveUsername(automation.get("_createdBy", ""))
|
||||
job["mandate"] = _resolveMandateLabel(automation.get("mandateId", ""))
|
||||
job["featureInstance"] = _resolveFeatureLabel(automation.get("featureInstanceId", ""))
|
||||
else:
|
||||
job["name"] = getattr(automation, "label", "")
|
||||
job["createdBy"] = _resolveUsername(getattr(automation, "_createdBy", ""))
|
||||
job["mandate"] = _resolveMandateLabel(getattr(automation, "mandateId", ""))
|
||||
job["featureInstance"] = _resolveFeatureLabel(getattr(automation, "featureInstanceId", ""))
|
||||
else:
|
||||
job["name"] = "(orphaned)"
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not enrich automation events with context: {e}")
|
||||
|
||||
return jobs
|
||||
|
||||
|
||||
@router.get("")
|
||||
@limiter.limit("30/minute")
|
||||
def get_all_automation_events(
|
||||
request: Request,
|
||||
currentUser: User = Depends(requireSysAdminRole)
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all active scheduler jobs (sysadmin only).
|
||||
Each job is enriched with context from its automation definition
|
||||
(name, mandate, feature instance, creator) for readability.
|
||||
"""
|
||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"),
|
||||
currentUser: User = Depends(requireSysAdminRole),
|
||||
):
|
||||
"""Get all active scheduler jobs with pagination support (sysadmin only)."""
|
||||
try:
|
||||
from modules.shared.eventManagement import eventManager
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
from modules.features.automation.mainAutomation import getAutomationServices
|
||||
|
||||
if not eventManager.scheduler:
|
||||
return []
|
||||
|
||||
# 1. Collect all scheduler jobs
|
||||
jobs = []
|
||||
automationIds = []
|
||||
for job in eventManager.scheduler.get_jobs():
|
||||
if job.id.startswith("automation."):
|
||||
automationId = job.id.replace("automation.", "")
|
||||
automationIds.append(automationId)
|
||||
jobs.append({
|
||||
"eventId": job.id,
|
||||
"automationId": automationId,
|
||||
"nextRunTime": str(job.next_run_time) if job.next_run_time else None,
|
||||
"trigger": str(job.trigger) if job.trigger else None,
|
||||
"name": "",
|
||||
"createdBy": "",
|
||||
"mandate": "",
|
||||
"featureInstance": ""
|
||||
})
|
||||
|
||||
# 2. Enrich with context from automation definitions
|
||||
if jobs:
|
||||
paginationParams: Optional[PaginationParams] = None
|
||||
if pagination:
|
||||
try:
|
||||
rootInterface = getRootInterface()
|
||||
eventUser = rootInterface.getUserByUsername("event")
|
||||
if eventUser:
|
||||
services = getAutomationServices(currentUser, mandateId=None, featureInstanceId=None)
|
||||
allAutomations = services.interfaceDbAutomation.getAllAutomationDefinitionsWithRBAC(eventUser)
|
||||
|
||||
# Build lookup by automation ID
|
||||
automationLookup = {}
|
||||
for a in allAutomations:
|
||||
aId = a.get("id", "") if isinstance(a, dict) else getattr(a, "id", "")
|
||||
automationLookup[aId] = a
|
||||
|
||||
# Caches for resolving UUIDs to names
|
||||
_userCache = {}
|
||||
_mandateCache = {}
|
||||
_featureCache = {}
|
||||
|
||||
def _resolveUsername(userId):
|
||||
if not userId:
|
||||
return ""
|
||||
if userId not in _userCache:
|
||||
try:
|
||||
user = rootInterface.getUser(userId)
|
||||
_userCache[userId] = user.username if user else userId[:8]
|
||||
except Exception:
|
||||
_userCache[userId] = userId[:8]
|
||||
return _userCache[userId]
|
||||
|
||||
def _resolveMandateLabel(mandateId):
|
||||
if not mandateId:
|
||||
return ""
|
||||
if mandateId not in _mandateCache:
|
||||
try:
|
||||
mandate = rootInterface.getMandate(mandateId)
|
||||
_mandateCache[mandateId] = getattr(mandate, "label", None) or mandateId[:8]
|
||||
except Exception:
|
||||
_mandateCache[mandateId] = mandateId[:8]
|
||||
return _mandateCache[mandateId]
|
||||
|
||||
def _resolveFeatureLabel(featureInstanceId):
|
||||
if not featureInstanceId:
|
||||
return ""
|
||||
if featureInstanceId not in _featureCache:
|
||||
try:
|
||||
instance = rootInterface.getFeatureInstance(featureInstanceId)
|
||||
_featureCache[featureInstanceId] = getattr(instance, "label", None) or getattr(instance, "featureCode", None) or featureInstanceId[:8]
|
||||
except Exception:
|
||||
_featureCache[featureInstanceId] = featureInstanceId[:8]
|
||||
return _featureCache[featureInstanceId]
|
||||
|
||||
# Enrich each job
|
||||
for job in jobs:
|
||||
automation = automationLookup.get(job["automationId"])
|
||||
if automation:
|
||||
if isinstance(automation, dict):
|
||||
job["name"] = automation.get("label", "")
|
||||
job["createdBy"] = _resolveUsername(automation.get("_createdBy", ""))
|
||||
job["mandate"] = _resolveMandateLabel(automation.get("mandateId", ""))
|
||||
job["featureInstance"] = _resolveFeatureLabel(automation.get("featureInstanceId", ""))
|
||||
else:
|
||||
job["name"] = getattr(automation, "label", "")
|
||||
job["createdBy"] = _resolveUsername(getattr(automation, "_createdBy", ""))
|
||||
job["mandate"] = _resolveMandateLabel(getattr(automation, "mandateId", ""))
|
||||
job["featureInstance"] = _resolveFeatureLabel(getattr(automation, "featureInstanceId", ""))
|
||||
else:
|
||||
job["name"] = "(orphaned)"
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not enrich automation events with context: {e}")
|
||||
|
||||
return jobs
|
||||
paginationDict = json.loads(pagination)
|
||||
if paginationDict:
|
||||
paginationDict = normalize_pagination_dict(paginationDict)
|
||||
paginationParams = PaginationParams(**paginationDict)
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}")
|
||||
|
||||
enriched = _buildEnrichedAutomationEvents(currentUser)
|
||||
filtered = _applyFiltersAndSort(enriched, paginationParams)
|
||||
|
||||
if paginationParams:
|
||||
totalItems = len(filtered)
|
||||
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
|
||||
startIdx = (paginationParams.page - 1) * paginationParams.pageSize
|
||||
endIdx = startIdx + paginationParams.pageSize
|
||||
return {
|
||||
"items": filtered[startIdx:endIdx],
|
||||
"pagination": PaginationMetadata(
|
||||
currentPage=paginationParams.page,
|
||||
pageSize=paginationParams.pageSize,
|
||||
totalItems=totalItems,
|
||||
totalPages=totalPages,
|
||||
sort=paginationParams.sort,
|
||||
filters=paginationParams.filters,
|
||||
).model_dump(),
|
||||
}
|
||||
|
||||
return {"items": enriched, "pagination": None}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting automation events: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Error getting automation events: {str(e)}"
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=f"Error getting automation events: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/filter-values")
|
||||
@limiter.limit("60/minute")
|
||||
def get_automation_event_filter_values(
|
||||
request: Request,
|
||||
column: str = Query(..., description="Column key"),
|
||||
pagination: Optional[str] = Query(None, description="JSON-encoded current filters"),
|
||||
currentUser: User = Depends(requireSysAdminRole),
|
||||
):
|
||||
"""Return distinct filter values for a column in automation events."""
|
||||
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 = _buildEnrichedAutomationEvents(currentUser)
|
||||
crossFiltered = _applyFiltersAndSort(enriched, crossFilterParams)
|
||||
return _extractDistinctValues(crossFiltered, column)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting filter values: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post("/sync")
|
||||
@limiter.limit("5/minute")
|
||||
|
|
|
|||
|
|
@ -14,7 +14,11 @@ from fastapi import APIRouter, HTTPException, Depends, Request, Query
|
|||
from typing import List, Dict, Any, Optional
|
||||
from fastapi import status
|
||||
import logging
|
||||
import json
|
||||
import math
|
||||
from pydantic import BaseModel, Field
|
||||
from modules.datamodels.datamodelPagination import PaginationParams, PaginationMetadata, normalize_pagination_dict
|
||||
from modules.routes.routeDataUsers import _applyFiltersAndSort, _extractDistinctValues
|
||||
|
||||
from modules.auth import limiter, getRequestContext, RequestContext, requireSysAdminRole
|
||||
from modules.datamodels.datamodelUam import User, UserInDB
|
||||
|
|
@ -433,6 +437,35 @@ 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="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])
|
||||
@limiter.limit("60/minute")
|
||||
def get_feature_instance(
|
||||
|
|
@ -783,27 +816,55 @@ def sync_instance_roles(
|
|||
# Template Role Endpoints (SysAdmin only)
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/templates/roles", response_model=List[Dict[str, Any]])
|
||||
def _buildTemplateRolesList(featureCode: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Build the full template roles list."""
|
||||
rootInterface = getRootInterface()
|
||||
featureInterface = getFeatureInterface(rootInterface.db)
|
||||
roles = featureInterface.getTemplateRoles(featureCode)
|
||||
return [r.model_dump() for r in roles]
|
||||
|
||||
|
||||
@router.get("/templates/roles")
|
||||
@limiter.limit("60/minute")
|
||||
def list_template_roles(
|
||||
request: Request,
|
||||
featureCode: Optional[str] = Query(None, description="Filter by feature code"),
|
||||
sysAdmin: User = Depends(requireSysAdminRole)
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List global template roles.
|
||||
|
||||
SysAdmin only - returns template roles that are copied to new feature instances.
|
||||
|
||||
Args:
|
||||
featureCode: Optional filter by feature code
|
||||
"""
|
||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"),
|
||||
sysAdmin: User = Depends(requireSysAdminRole),
|
||||
):
|
||||
"""List global template roles with pagination support."""
|
||||
try:
|
||||
rootInterface = getRootInterface()
|
||||
featureInterface = getFeatureInterface(rootInterface.db)
|
||||
|
||||
roles = featureInterface.getTemplateRoles(featureCode)
|
||||
return [r.model_dump() for r in roles]
|
||||
paginationParams: Optional[PaginationParams] = None
|
||||
if pagination:
|
||||
try:
|
||||
paginationDict = json.loads(pagination)
|
||||
if paginationDict:
|
||||
paginationDict = normalize_pagination_dict(paginationDict)
|
||||
paginationParams = PaginationParams(**paginationDict)
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}")
|
||||
|
||||
enriched = _buildTemplateRolesList(featureCode)
|
||||
filtered = _applyFiltersAndSort(enriched, paginationParams)
|
||||
|
||||
if paginationParams:
|
||||
totalItems = len(filtered)
|
||||
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
|
||||
startIdx = (paginationParams.page - 1) * paginationParams.pageSize
|
||||
endIdx = startIdx + paginationParams.pageSize
|
||||
return {
|
||||
"items": filtered[startIdx:endIdx],
|
||||
"pagination": PaginationMetadata(
|
||||
currentPage=paginationParams.page,
|
||||
pageSize=paginationParams.pageSize,
|
||||
totalItems=totalItems,
|
||||
totalPages=totalPages,
|
||||
sort=paginationParams.sort,
|
||||
filters=paginationParams.filters,
|
||||
).model_dump(),
|
||||
}
|
||||
|
||||
return {"items": enriched, "pagination": None}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing template roles: {e}")
|
||||
|
|
@ -813,6 +874,39 @@ 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])
|
||||
@limiter.limit("10/minute")
|
||||
def create_template_role(
|
||||
|
|
@ -976,6 +1070,56 @@ 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="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])
|
||||
@limiter.limit("30/minute")
|
||||
def add_user_to_feature_instance(
|
||||
|
|
|
|||
|
|
@ -394,7 +394,10 @@ def get_access_rules(
|
|||
)
|
||||
|
||||
# Get rules with optional pagination
|
||||
# MandateAdmin: fetch all then filter by admin's mandates
|
||||
# MandateAdmin: fetch all then filter by admin's mandates.
|
||||
# NOTE: Cannot use DB-level pagination for MandateAdmin because
|
||||
# _isRoleInAdminMandates requires joining Role → mandateId which
|
||||
# isn't expressible via getRecordsetPaginated's recordFilter.
|
||||
if not isSysAdmin:
|
||||
allRules = interface.getAccessRules(
|
||||
roleLabel=roleLabel,
|
||||
|
|
@ -814,6 +817,11 @@ def list_roles(
|
|||
By default, only returns true global roles (mandateId=None, featureInstanceId=None, featureCode=None).
|
||||
Feature template roles are managed via /api/features/templates/roles.
|
||||
|
||||
NOTE: Base query (getAllRoles) already uses db.getRecordsetPaginated() internally.
|
||||
However pagination=None is passed here because post-processing adds computed fields
|
||||
(userCount, scopeType) and applies scope/mandate/template filtering that cannot run
|
||||
at the DB level. In-memory pagination is applied after all transformations.
|
||||
|
||||
Args:
|
||||
pagination: Optional pagination parameters (includes search, filters, sort)
|
||||
includeTemplates: If True, also include feature template roles (featureCode != None)
|
||||
|
|
@ -983,6 +991,77 @@ 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="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": 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])
|
||||
@limiter.limit("30/minute")
|
||||
def create_role(
|
||||
|
|
|
|||
|
|
@ -22,8 +22,10 @@ from modules.auth import limiter, requireSysAdminRole, getRequestContext, Reques
|
|||
# Import billing components
|
||||
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface, _getRootInterface
|
||||
from modules.serviceCenter.services.serviceBilling.mainServiceBilling import getService as getBillingService
|
||||
import json
|
||||
import math
|
||||
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
|
||||
from modules.routes.routeDataUsers import _applyFiltersAndSort
|
||||
from modules.routes.routeDataUsers import _applyFiltersAndSort, _extractDistinctValues, _handleFilterValuesRequest
|
||||
from modules.datamodels.datamodelBilling import (
|
||||
BillingAccount,
|
||||
BillingTransaction,
|
||||
|
|
@ -1402,49 +1404,187 @@ def getUsersForMandate(
|
|||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/admin/transactions/{targetMandateId}", response_model=List[TransactionResponse])
|
||||
def _enrichTransactionRows(transactions) -> List[Dict[str, Any]]:
|
||||
"""Convert raw transaction dicts to enriched TransactionResponse rows with resolved usernames."""
|
||||
result = []
|
||||
for t in transactions:
|
||||
row = TransactionResponse(
|
||||
id=t.get("id"),
|
||||
accountId=t.get("accountId"),
|
||||
transactionType=TransactionTypeEnum(t.get("transactionType", "DEBIT")),
|
||||
amount=t.get("amount", 0.0),
|
||||
description=t.get("description", ""),
|
||||
referenceType=ReferenceTypeEnum(t["referenceType"]) if t.get("referenceType") else None,
|
||||
workflowId=t.get("workflowId"),
|
||||
featureCode=t.get("featureCode"),
|
||||
featureInstanceId=t.get("featureInstanceId"),
|
||||
aicoreProvider=t.get("aicoreProvider"),
|
||||
aicoreModel=t.get("aicoreModel"),
|
||||
createdByUserId=t.get("createdByUserId"),
|
||||
createdAt=t.get("_createdAt")
|
||||
)
|
||||
result.append(row.model_dump())
|
||||
|
||||
try:
|
||||
from modules.interfaces.interfaceDbUam import _getRootInterface as getUamRoot
|
||||
uamInterface = getUamRoot()
|
||||
userNames: Dict[str, str] = {}
|
||||
for row in result:
|
||||
uid = row.get("createdByUserId")
|
||||
if uid and uid not in userNames:
|
||||
try:
|
||||
user = uamInterface.getUser(uid)
|
||||
userNames[uid] = user.get("username", uid[:8]) if user else uid[:8]
|
||||
except Exception:
|
||||
userNames[uid] = uid[:8]
|
||||
row["userName"] = userNames.get(uid, "") if uid else ""
|
||||
except Exception:
|
||||
for row in result:
|
||||
row["userName"] = row.get("createdByUserId", "")[:8] if row.get("createdByUserId") else ""
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _buildTransactionsList(ctx: RequestContext, targetMandateId: str) -> List[Dict[str, Any]]:
|
||||
"""Build the full enriched transactions list for a mandate."""
|
||||
billingInterface = getBillingInterface(ctx.user, targetMandateId)
|
||||
transactions = billingInterface.getTransactionsByMandate(targetMandateId, limit=5000)
|
||||
|
||||
result = []
|
||||
for t in transactions:
|
||||
row = TransactionResponse(
|
||||
id=t.get("id"),
|
||||
accountId=t.get("accountId"),
|
||||
transactionType=TransactionTypeEnum(t.get("transactionType", "DEBIT")),
|
||||
amount=t.get("amount", 0.0),
|
||||
description=t.get("description", ""),
|
||||
referenceType=ReferenceTypeEnum(t["referenceType"]) if t.get("referenceType") else None,
|
||||
workflowId=t.get("workflowId"),
|
||||
featureCode=t.get("featureCode"),
|
||||
featureInstanceId=t.get("featureInstanceId"),
|
||||
aicoreProvider=t.get("aicoreProvider"),
|
||||
aicoreModel=t.get("aicoreModel"),
|
||||
createdByUserId=t.get("createdByUserId"),
|
||||
createdAt=t.get("_createdAt")
|
||||
)
|
||||
result.append(row.model_dump())
|
||||
|
||||
# Resolve user names
|
||||
try:
|
||||
from modules.interfaces.interfaceDbUam import _getRootInterface as getUamRoot
|
||||
uamInterface = getUamRoot()
|
||||
userNames: Dict[str, str] = {}
|
||||
for row in result:
|
||||
uid = row.get("createdByUserId")
|
||||
if uid and uid not in userNames:
|
||||
try:
|
||||
user = uamInterface.getUser(uid)
|
||||
userNames[uid] = user.get("username", uid[:8]) if user else uid[:8]
|
||||
except Exception:
|
||||
userNames[uid] = uid[:8]
|
||||
row["userName"] = userNames.get(uid, "") if uid else ""
|
||||
except Exception:
|
||||
for row in result:
|
||||
row["userName"] = row.get("createdByUserId", "")[:8] if row.get("createdByUserId") else ""
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/admin/transactions/{targetMandateId}")
|
||||
@limiter.limit("30/minute")
|
||||
def getTransactionsAdmin(
|
||||
request: Request,
|
||||
targetMandateId: str = Path(..., description="Mandate ID"),
|
||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"),
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
ctx: RequestContext = Depends(getRequestContext),
|
||||
):
|
||||
"""
|
||||
Get all transactions for a mandate.
|
||||
Access: SysAdmin (any mandate) or MandateAdmin (own mandate).
|
||||
"""
|
||||
"""Get all transactions for a mandate with pagination support."""
|
||||
if not _isAdminOfMandate(ctx, targetMandateId):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required for this mandate")
|
||||
try:
|
||||
billingInterface = getBillingInterface(ctx.user, targetMandateId)
|
||||
transactions = billingInterface.getTransactionsByMandate(targetMandateId, limit=limit)
|
||||
|
||||
result = []
|
||||
for t in transactions:
|
||||
result.append(TransactionResponse(
|
||||
id=t.get("id"),
|
||||
accountId=t.get("accountId"),
|
||||
transactionType=TransactionTypeEnum(t.get("transactionType", "DEBIT")),
|
||||
amount=t.get("amount", 0.0),
|
||||
description=t.get("description", ""),
|
||||
referenceType=ReferenceTypeEnum(t["referenceType"]) if t.get("referenceType") else None,
|
||||
workflowId=t.get("workflowId"),
|
||||
featureCode=t.get("featureCode"),
|
||||
featureInstanceId=t.get("featureInstanceId"),
|
||||
aicoreProvider=t.get("aicoreProvider"),
|
||||
aicoreModel=t.get("aicoreModel"),
|
||||
createdByUserId=t.get("createdByUserId"),
|
||||
createdAt=t.get("_createdAt")
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
paginationParams: Optional[PaginationParams] = None
|
||||
if pagination:
|
||||
try:
|
||||
paginationDict = json.loads(pagination)
|
||||
if paginationDict:
|
||||
paginationDict = normalize_pagination_dict(paginationDict)
|
||||
paginationParams = PaginationParams(**paginationDict)
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}")
|
||||
|
||||
if paginationParams:
|
||||
# DB-level pagination — enrich only the returned page
|
||||
billingInterface = getBillingInterface(ctx.user, targetMandateId)
|
||||
result = billingInterface.getTransactionsByMandate(targetMandateId, pagination=paginationParams)
|
||||
transactions = result.items if hasattr(result, 'items') else result
|
||||
enrichedItems = _enrichTransactionRows(transactions)
|
||||
return {
|
||||
"items": enrichedItems,
|
||||
"pagination": PaginationMetadata(
|
||||
currentPage=paginationParams.page,
|
||||
pageSize=paginationParams.pageSize,
|
||||
totalItems=result.totalItems if hasattr(result, 'totalItems') else len(enrichedItems),
|
||||
totalPages=result.totalPages if hasattr(result, 'totalPages') else 0,
|
||||
sort=paginationParams.sort,
|
||||
filters=paginationParams.filters,
|
||||
).model_dump(),
|
||||
}
|
||||
|
||||
enriched = _buildTransactionsList(ctx, targetMandateId)
|
||||
return {"items": enriched, "pagination": None}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting billing transactions for mandate {targetMandateId}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/admin/transactions/{targetMandateId}/filter-values")
|
||||
@limiter.limit("60/minute")
|
||||
def getTransactionFilterValues(
|
||||
request: Request,
|
||||
targetMandateId: str = Path(..., description="Mandate ID"),
|
||||
column: str = Query(..., description="Column key"),
|
||||
pagination: Optional[str] = Query(None, description="JSON-encoded current filters"),
|
||||
ctx: RequestContext = Depends(getRequestContext),
|
||||
):
|
||||
"""Return distinct filter values for a column in mandate transactions."""
|
||||
if not _isAdminOfMandate(ctx, targetMandateId):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required for this mandate")
|
||||
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
|
||||
|
||||
# Try SQL DISTINCT for native DB columns; fallback to in-memory for enriched columns (e.g. userName)
|
||||
try:
|
||||
rootBillingInterface = _getRootInterface()
|
||||
recordFilter = {"mandateId": targetMandateId}
|
||||
values = rootBillingInterface.db.getDistinctColumnValues(
|
||||
BillingTransaction, column, crossFilterParams, recordFilter
|
||||
)
|
||||
return sorted(values, key=lambda v: str(v).lower())
|
||||
except Exception:
|
||||
enriched = _buildTransactionsList(ctx, targetMandateId)
|
||||
crossFiltered = _applyFiltersAndSort(enriched, crossFilterParams)
|
||||
return _extractDistinctValues(crossFiltered, column)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting filter values for transactions: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Mandate View Endpoints (for Admins)
|
||||
# =============================================================================
|
||||
|
|
@ -1892,3 +2032,50 @@ def getUserViewTransactions(
|
|||
except Exception as e:
|
||||
logger.error(f"Error getting user view transactions: {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"),
|
||||
ctx: RequestContext = Depends(getRequestContext)
|
||||
):
|
||||
"""Return distinct filter values for a column in user transactions."""
|
||||
try:
|
||||
billingInterface = getBillingInterface(ctx.user, ctx.mandateId)
|
||||
scope = _getBillingDataScope(ctx.user)
|
||||
if scope.isGlobalAdmin:
|
||||
mandateIds = None
|
||||
else:
|
||||
mandateIds = scope.adminMandateIds + scope.memberMandateIds
|
||||
if not mandateIds:
|
||||
return []
|
||||
allTransactions = billingInterface.getUserTransactionsForMandates(mandateIds, limit=10000)
|
||||
allTransactions = _filterTransactionsByScope(allTransactions, scope)
|
||||
transactionDicts = []
|
||||
for t in allTransactions:
|
||||
transactionDicts.append({
|
||||
"id": t.get("id"),
|
||||
"accountId": t.get("accountId"),
|
||||
"transactionType": t.get("transactionType", "DEBIT"),
|
||||
"amount": t.get("amount", 0.0),
|
||||
"description": t.get("description", ""),
|
||||
"referenceType": t.get("referenceType"),
|
||||
"workflowId": t.get("workflowId"),
|
||||
"featureCode": t.get("featureCode"),
|
||||
"featureInstanceId": t.get("featureInstanceId"),
|
||||
"aicoreProvider": t.get("aicoreProvider"),
|
||||
"aicoreModel": t.get("aicoreModel"),
|
||||
"createdByUserId": t.get("createdByUserId"),
|
||||
"createdAt": t.get("_createdAt"),
|
||||
"mandateId": t.get("mandateId"),
|
||||
"mandateName": t.get("mandateName"),
|
||||
"userId": t.get("userId"),
|
||||
"userName": t.get("userName"),
|
||||
})
|
||||
return _handleFilterValuesRequest(transactionDicts, column, pagination)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting filter values for user transactions: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
|
|
|||
|
|
@ -147,6 +147,11 @@ async def get_connections(
|
|||
try:
|
||||
interface = getInterface(currentUser)
|
||||
|
||||
# NOTE: Cannot use db.getRecordsetPaginated() here because each connection
|
||||
# is enriched with computed tokenStatus/tokenExpiresAt (requires per-row DB lookup).
|
||||
# Token refresh also may trigger re-fetch. Connections per user are typically < 10,
|
||||
# so in-memory pagination is acceptable.
|
||||
|
||||
# Parse pagination parameter
|
||||
paginationParams = None
|
||||
if pagination:
|
||||
|
|
@ -287,6 +292,42 @@ async def get_connections(
|
|||
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)
|
||||
@limiter.limit("10/minute")
|
||||
def create_connection(
|
||||
|
|
|
|||
|
|
@ -214,6 +214,56 @@ def get_files(
|
|||
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 = {"_createdBy": 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)
|
||||
@limiter.limit("10/minute")
|
||||
async def upload_file(
|
||||
|
|
|
|||
|
|
@ -164,6 +164,66 @@ def get_mandates(
|
|||
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="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)
|
||||
@limiter.limit("30/minute")
|
||||
def get_mandate(
|
||||
|
|
@ -540,6 +600,63 @@ 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="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)
|
||||
@limiter.limit("30/minute")
|
||||
def add_user_to_mandate(
|
||||
|
|
|
|||
|
|
@ -81,6 +81,29 @@ def get_prompts(
|
|||
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)
|
||||
@limiter.limit("10/minute")
|
||||
def create_prompt(
|
||||
|
|
|
|||
|
|
@ -69,6 +69,55 @@ def _isAdminForUser(context: RequestContext, targetUserId: str) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def _extractDistinctValues(items: List[Dict[str, Any]], columnKey: str) -> 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 = val.get("en") or next((v for v in val.values() if isinstance(v, str) and v), None)
|
||||
if text:
|
||||
values.add(str(text))
|
||||
else:
|
||||
values.add(str(val))
|
||||
return sorted(values, key=lambda v: v.lower())
|
||||
|
||||
|
||||
def _handleFilterValuesRequest(
|
||||
items: List[Dict[str, Any]],
|
||||
column: str,
|
||||
paginationJson: Optional[str] = None,
|
||||
) -> List[str]:
|
||||
"""
|
||||
Generic handler for /filter-values endpoints.
|
||||
Applies all active filters EXCEPT the one for the requested column (cross-filtering),
|
||||
then extracts distinct values for that column.
|
||||
"""
|
||||
crossFilterParams: Optional[PaginationParams] = None
|
||||
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)
|
||||
|
||||
|
||||
def _applyFiltersAndSort(items: List[Dict[str, Any]], paginationParams: Optional[PaginationParams]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Apply filters and sorting to a list of items.
|
||||
|
|
@ -121,7 +170,6 @@ def _applyFiltersAndSort(items: List[Dict[str, Any]], paginationParams: Optional
|
|||
if itemValue is None:
|
||||
return False
|
||||
|
||||
# Convert to string for comparison if needed
|
||||
itemStr = str(itemValue).lower()
|
||||
valueStr = str(v).lower()
|
||||
|
||||
|
|
@ -147,6 +195,42 @@ def _applyFiltersAndSort(items: List[Dict[str, Any]], paginationParams: Optional
|
|||
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]
|
||||
|
|
@ -159,23 +243,25 @@ def _applyFiltersAndSort(items: List[Dict[str, Any]], paginationParams: Optional
|
|||
|
||||
result = [item for item in result if matchesFilter(item, field, operator, value)]
|
||||
|
||||
# Apply sorting
|
||||
# Apply sorting — None values always last
|
||||
if paginationParams.sort:
|
||||
for sortField in reversed(paginationParams.sort):
|
||||
fieldName = sortField.field
|
||||
ascending = sortField.direction == 'asc'
|
||||
|
||||
def getSortKey(item: Dict[str, Any]):
|
||||
value = item.get(fieldName)
|
||||
if value is None:
|
||||
return (1, '') # Nulls last
|
||||
if isinstance(value, bool):
|
||||
return (0, not value if ascending else value)
|
||||
if isinstance(value, (int, float)):
|
||||
return (0, value)
|
||||
return (0, str(value).lower())
|
||||
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]
|
||||
|
||||
result = sorted(result, key=getSortKey, reverse=not ascending)
|
||||
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
|
||||
|
||||
|
|
@ -291,38 +377,23 @@ def get_users(
|
|||
pagination=None
|
||||
)
|
||||
elif context.hasSysAdminRole:
|
||||
# SysAdmin without mandateId sees all users
|
||||
# Get all users via interface method (returns Pydantic User models)
|
||||
allUserModels = appInterface.getAllUsers()
|
||||
# Convert to dictionaries for filtering/sorting
|
||||
cleanedUsers = [u.model_dump() for u in allUserModels]
|
||||
# SysAdmin without mandateId — DB-level pagination via interface
|
||||
result = appInterface.getAllUsers(paginationParams)
|
||||
|
||||
# Apply server-side filtering and sorting
|
||||
filteredUsers = _applyFiltersAndSort(cleanedUsers, paginationParams)
|
||||
|
||||
# Convert to User objects
|
||||
users = [User(**u) for u in filteredUsers]
|
||||
|
||||
if paginationParams:
|
||||
import math
|
||||
totalItems = len(users)
|
||||
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
|
||||
startIdx = (paginationParams.page - 1) * paginationParams.pageSize
|
||||
endIdx = startIdx + paginationParams.pageSize
|
||||
paginatedUsers = users[startIdx:endIdx]
|
||||
|
||||
if paginationParams and hasattr(result, 'items'):
|
||||
return PaginatedResponse(
|
||||
items=paginatedUsers,
|
||||
items=result.items,
|
||||
pagination=PaginationMetadata(
|
||||
currentPage=paginationParams.page,
|
||||
pageSize=paginationParams.pageSize,
|
||||
totalItems=totalItems,
|
||||
totalPages=totalPages,
|
||||
totalItems=result.totalItems,
|
||||
totalPages=result.totalPages,
|
||||
sort=paginationParams.sort,
|
||||
filters=paginationParams.filters
|
||||
)
|
||||
)
|
||||
else:
|
||||
users = result if isinstance(result, list) else (result.items if hasattr(result, 'items') else [])
|
||||
return PaginatedResponse(
|
||||
items=users,
|
||||
pagination=None
|
||||
|
|
@ -407,6 +478,88 @@ def get_users(
|
|||
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)
|
||||
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)
|
||||
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 []
|
||||
seenUserIds = set()
|
||||
users = []
|
||||
for mid in adminMandateIds:
|
||||
mandateUsers = rootInterface.getUsersByMandate(mid)
|
||||
uList = mandateUsers if isinstance(mandateUsers, list) else (mandateUsers.items if hasattr(mandateUsers, 'items') else [])
|
||||
for u in uList:
|
||||
uid = u.get("id") if isinstance(u, dict) else getattr(u, "id", None)
|
||||
if uid and uid not in seenUserIds:
|
||||
seenUserIds.add(uid)
|
||||
users.append(u)
|
||||
items = [u.model_dump() if hasattr(u, 'model_dump') else u for u in users]
|
||||
return _handleFilterValuesRequest(items, column, pagination)
|
||||
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)
|
||||
@limiter.limit("30/minute")
|
||||
def get_user(
|
||||
|
|
|
|||
|
|
@ -420,6 +420,13 @@ def list_invitations(
|
|||
|
||||
Requires Mandate-Admin role. Returns all invitations created for this mandate.
|
||||
|
||||
NOTE: Cannot use db.getRecordsetPaginated() because:
|
||||
- Computed status fields (isExpired, isUsedUp) are derived in-memory
|
||||
- Filtering by revoked/used/expired requires post-fetch logic
|
||||
- Invitation volume per mandate is typically low (< 100)
|
||||
When this endpoint needs FormGeneratorTable pagination, add PaginatedResponse
|
||||
support with in-memory slicing (similar to routeDataConnections).
|
||||
|
||||
Args:
|
||||
includeUsed: Include invitations that have reached maxUses
|
||||
includeExpired: Include expired invitations
|
||||
|
|
@ -485,6 +492,54 @@ 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="X-Mandate-Id header is required")
|
||||
if not _hasMandateAdminRole(context):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="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])
|
||||
@limiter.limit("30/minute")
|
||||
def revoke_invitation(
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ from modules.datamodels.datamodelMessaging import (
|
|||
MessagingSubscriptionExecutionResult
|
||||
)
|
||||
from modules.datamodels.datamodelUam import User
|
||||
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata
|
||||
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
|
||||
|
||||
# Configure logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -48,6 +48,8 @@ def get_subscriptions(
|
|||
if pagination:
|
||||
try:
|
||||
paginationDict = json.loads(pagination)
|
||||
if paginationDict:
|
||||
paginationDict = normalize_pagination_dict(paginationDict)
|
||||
paginationParams = PaginationParams(**paginationDict) if paginationDict else None
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
raise HTTPException(
|
||||
|
|
@ -185,6 +187,8 @@ def get_subscription_registrations(
|
|||
if pagination:
|
||||
try:
|
||||
paginationDict = json.loads(pagination)
|
||||
if paginationDict:
|
||||
paginationDict = normalize_pagination_dict(paginationDict)
|
||||
paginationParams = PaginationParams(**paginationDict) if paginationDict else None
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
raise HTTPException(
|
||||
|
|
@ -277,6 +281,8 @@ def get_my_registrations(
|
|||
if pagination:
|
||||
try:
|
||||
paginationDict = json.loads(pagination)
|
||||
if paginationDict:
|
||||
paginationDict = normalize_pagination_dict(paginationDict)
|
||||
paginationParams = PaginationParams(**paginationDict) if paginationDict else None
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
raise HTTPException(
|
||||
|
|
@ -450,6 +456,8 @@ def get_deliveries(
|
|||
if pagination:
|
||||
try:
|
||||
paginationDict = json.loads(pagination)
|
||||
if paginationDict:
|
||||
paginationDict = normalize_pagination_dict(paginationDict)
|
||||
paginationParams = PaginationParams(**paginationDict) if paginationDict else None
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
raise HTTPException(
|
||||
|
|
|
|||
|
|
@ -227,33 +227,22 @@ async def get_projects(
|
|||
context.user, mandateId=mandateId, featureInstanceId=instanceId
|
||||
)
|
||||
recordFilter = {"featureInstanceId": instanceId}
|
||||
items = interface.getProjekte(recordFilter=recordFilter)
|
||||
paginationParams = _parsePagination(pagination)
|
||||
if paginationParams:
|
||||
if paginationParams.sort:
|
||||
for sort_field in reversed(paginationParams.sort):
|
||||
field_name = sort_field.field
|
||||
direction = sort_field.direction.lower()
|
||||
items.sort(
|
||||
key=lambda x: getattr(x, field_name, None),
|
||||
reverse=(direction == "desc")
|
||||
result = interface.getProjekte(pagination=paginationParams, recordFilter=recordFilter)
|
||||
if hasattr(result, 'items'):
|
||||
return PaginatedResponse(
|
||||
items=result.items,
|
||||
pagination=PaginationMetadata(
|
||||
currentPage=paginationParams.page,
|
||||
pageSize=paginationParams.pageSize,
|
||||
totalItems=result.totalItems,
|
||||
totalPages=result.totalPages,
|
||||
sort=paginationParams.sort or [],
|
||||
filters=paginationParams.filters
|
||||
)
|
||||
total_items = len(items)
|
||||
total_pages = (total_items + paginationParams.pageSize - 1) // paginationParams.pageSize
|
||||
start_idx = (paginationParams.page - 1) * paginationParams.pageSize
|
||||
end_idx = start_idx + paginationParams.pageSize
|
||||
paginated_items = items[start_idx:end_idx]
|
||||
return PaginatedResponse(
|
||||
items=paginated_items,
|
||||
pagination=PaginationMetadata(
|
||||
currentPage=paginationParams.page,
|
||||
pageSize=paginationParams.pageSize,
|
||||
totalItems=total_items,
|
||||
totalPages=total_pages,
|
||||
sort=paginationParams.sort or [],
|
||||
filters=paginationParams.filters
|
||||
)
|
||||
)
|
||||
items = interface.getProjekte(recordFilter=recordFilter)
|
||||
return PaginatedResponse(items=items, pagination=None)
|
||||
|
||||
|
||||
|
|
@ -359,33 +348,22 @@ async def get_parcels(
|
|||
context.user, mandateId=mandateId, featureInstanceId=instanceId
|
||||
)
|
||||
recordFilter = {"featureInstanceId": instanceId}
|
||||
items = interface.getParzellen(recordFilter=recordFilter)
|
||||
paginationParams = _parsePagination(pagination)
|
||||
if paginationParams:
|
||||
if paginationParams.sort:
|
||||
for sort_field in reversed(paginationParams.sort):
|
||||
field_name = sort_field.field
|
||||
direction = sort_field.direction.lower()
|
||||
items.sort(
|
||||
key=lambda x: getattr(x, field_name, None),
|
||||
reverse=(direction == "desc")
|
||||
result = interface.getParzellen(pagination=paginationParams, recordFilter=recordFilter)
|
||||
if hasattr(result, 'items'):
|
||||
return PaginatedResponse(
|
||||
items=result.items,
|
||||
pagination=PaginationMetadata(
|
||||
currentPage=paginationParams.page,
|
||||
pageSize=paginationParams.pageSize,
|
||||
totalItems=result.totalItems,
|
||||
totalPages=result.totalPages,
|
||||
sort=paginationParams.sort or [],
|
||||
filters=paginationParams.filters
|
||||
)
|
||||
total_items = len(items)
|
||||
total_pages = (total_items + paginationParams.pageSize - 1) // paginationParams.pageSize
|
||||
start_idx = (paginationParams.page - 1) * paginationParams.pageSize
|
||||
end_idx = start_idx + paginationParams.pageSize
|
||||
paginated_items = items[start_idx:end_idx]
|
||||
return PaginatedResponse(
|
||||
items=paginated_items,
|
||||
pagination=PaginationMetadata(
|
||||
currentPage=paginationParams.page,
|
||||
pageSize=paginationParams.pageSize,
|
||||
totalItems=total_items,
|
||||
totalPages=total_pages,
|
||||
sort=paginationParams.sort or [],
|
||||
filters=paginationParams.filters
|
||||
)
|
||||
)
|
||||
items = interface.getParzellen(recordFilter=recordFilter)
|
||||
return PaginatedResponse(items=items, pagination=None)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -12,13 +12,17 @@ Endpoints:
|
|||
- POST /api/subscription/force-cancel — sysadmin immediate cancel (by ID)
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Request
|
||||
from fastapi import APIRouter, HTTPException, Depends, Request, Query
|
||||
from fastapi import status
|
||||
from typing import Dict, Any, List, Optional
|
||||
import logging
|
||||
import json
|
||||
import math
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from modules.auth import limiter, getRequestContext, RequestContext
|
||||
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
|
||||
from modules.routes.routeDataUsers import _applyFiltersAndSort, _extractDistinctValues
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -303,16 +307,8 @@ def verifyCheckout(
|
|||
# SysAdmin: global subscription overview
|
||||
# =============================================================================
|
||||
|
||||
@router.get("/admin/all", response_model=List[Dict[str, Any]])
|
||||
@limiter.limit("30/minute")
|
||||
def getAllSubscriptions(
|
||||
request: Request,
|
||||
context: RequestContext = Depends(getRequestContext),
|
||||
):
|
||||
"""SysAdmin: list ALL subscriptions across all mandates with enriched metadata."""
|
||||
if not context.hasSysAdminRole:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Sysadmin role required")
|
||||
|
||||
def _buildEnrichedSubscriptions() -> List[Dict[str, Any]]:
|
||||
"""Build the full enriched subscription list (shared by list + filter-values endpoints)."""
|
||||
from modules.interfaces.interfaceDbSubscription import _getRootInterface as getSubRootInterface
|
||||
from modules.datamodels.datamodelSubscription import BUILTIN_PLANS, OPERATIVE_STATUSES
|
||||
|
||||
|
|
@ -362,3 +358,80 @@ def getAllSubscriptions(
|
|||
enriched.append(sub)
|
||||
|
||||
return enriched
|
||||
|
||||
|
||||
@router.get("/admin/all")
|
||||
@limiter.limit("30/minute")
|
||||
def getAllSubscriptions(
|
||||
request: Request,
|
||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||
context: RequestContext = Depends(getRequestContext),
|
||||
):
|
||||
"""SysAdmin: list ALL subscriptions across all mandates with enriched metadata."""
|
||||
if not context.hasSysAdminRole:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Sysadmin role required")
|
||||
|
||||
paginationParams: Optional[PaginationParams] = None
|
||||
if pagination:
|
||||
try:
|
||||
paginationDict = json.loads(pagination)
|
||||
if paginationDict:
|
||||
paginationDict = normalize_pagination_dict(paginationDict)
|
||||
paginationParams = PaginationParams(**paginationDict)
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}")
|
||||
|
||||
enriched = _buildEnrichedSubscriptions()
|
||||
filtered = _applyFiltersAndSort(enriched, paginationParams)
|
||||
|
||||
if paginationParams:
|
||||
totalItems = len(filtered)
|
||||
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
|
||||
startIdx = (paginationParams.page - 1) * paginationParams.pageSize
|
||||
endIdx = startIdx + paginationParams.pageSize
|
||||
pageItems = filtered[startIdx:endIdx]
|
||||
return {
|
||||
"items": pageItems,
|
||||
"pagination": PaginationMetadata(
|
||||
currentPage=paginationParams.page,
|
||||
pageSize=paginationParams.pageSize,
|
||||
totalItems=totalItems,
|
||||
totalPages=totalPages,
|
||||
sort=paginationParams.sort,
|
||||
filters=paginationParams.filters,
|
||||
).model_dump(),
|
||||
}
|
||||
|
||||
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="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)
|
||||
|
|
|
|||
|
|
@ -191,6 +191,15 @@ NAVIGATION_SECTIONS = [
|
|||
"order": 40,
|
||||
"adminOnly": True,
|
||||
},
|
||||
{
|
||||
"id": "admin-subscriptions",
|
||||
"objectKey": "ui.admin.subscriptions",
|
||||
"label": {"en": "Subscriptions", "de": "Abonnements", "fr": "Abonnements"},
|
||||
"icon": "FaFileContract",
|
||||
"path": "/admin/billing/subscriptions",
|
||||
"order": 50,
|
||||
"adminOnly": True,
|
||||
},
|
||||
],
|
||||
},
|
||||
# ── System ──
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,317 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<mxfile host="app.diagrams.net" modified="2025-01-22T00:00:00.000Z" agent="Python Script" version="21.0.0" type="device">
|
||||
<diagram id="container-diagram" name="Container Imports">
|
||||
<mxGraphModel dx="1434" dy="780" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1600" pageHeight="1200" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
<mxCell id="container_aichat" value="aichat\n(70 modules)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#666666;fontStyle=1;fontSize=11;" vertex="1" parent="1">
|
||||
<mxGeometry x="530" y="70" width="140" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="container_app" value="app\n(1 modules)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#666666;fontStyle=1;fontSize=11;" vertex="1" parent="1">
|
||||
<mxGeometry x="674" y="97" width="140" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="container_auth" value="auth\n(6 modules)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#666666;fontStyle=1;fontSize=11;" vertex="1" parent="1">
|
||||
<mxGeometry x="799" y="174" width="140" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="container_connectors" value="connectors\n(8 modules)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#666666;fontStyle=1;fontSize=11;" vertex="1" parent="1">
|
||||
<mxGeometry x="888" y="291" width="140" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="container_datamodels" value="datamodels\n(22 modules)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#666666;fontStyle=1;fontSize=11;" vertex="1" parent="1">
|
||||
<mxGeometry x="928" y="433" width="140" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="container_features_automation" value="features.automation\n(1 modules)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e2efda;strokeColor=#666666;fontStyle=1;fontSize=11;" vertex="1" parent="1">
|
||||
<mxGeometry x="914" y="579" width="140" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="container_features_chatbot" value="features.chatbot\n(6 modules)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e2efda;strokeColor=#666666;fontStyle=1;fontSize=11;" vertex="1" parent="1">
|
||||
<mxGeometry x="849" y="711" width="140" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="container_features_featureRegistry" value="features.featureRegistry\n(1 modules)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e2efda;strokeColor=#666666;fontStyle=1;fontSize=11;" vertex="1" parent="1">
|
||||
<mxGeometry x="740" y="810" width="140" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="container_features_neutralizer" value="features.neutralizer\n(5 modules)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e2efda;strokeColor=#666666;fontStyle=1;fontSize=11;" vertex="1" parent="1">
|
||||
<mxGeometry x="603" y="863" width="140" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="container_features_realestate" value="features.realestate\n(4 modules)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e2efda;strokeColor=#666666;fontStyle=1;fontSize=11;" vertex="1" parent="1">
|
||||
<mxGeometry x="456" y="863" width="140" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="container_features_trustee" value="features.trustee\n(3 modules)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e2efda;strokeColor=#666666;fontStyle=1;fontSize=11;" vertex="1" parent="1">
|
||||
<mxGeometry x="319" y="810" width="140" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="container_interfaces" value="interfaces\n(9 modules)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#666666;fontStyle=1;fontSize=11;" vertex="1" parent="1">
|
||||
<mxGeometry x="210" y="711" width="140" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="container_routes" value="routes\n(22 modules)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d0cee2;strokeColor=#666666;fontStyle=1;fontSize=11;" vertex="1" parent="1">
|
||||
<mxGeometry x="145" y="579" width="140" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="container_security" value="security\n(4 modules)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fad7ac;strokeColor=#666666;fontStyle=1;fontSize=11;" vertex="1" parent="1">
|
||||
<mxGeometry x="131" y="433" width="140" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="container_services" value="services\n(9 modules)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#b1ddf0;strokeColor=#666666;fontStyle=1;fontSize=11;" vertex="1" parent="1">
|
||||
<mxGeometry x="171" y="291" width="140" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="container_shared" value="shared\n(13 modules)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f0fff0;strokeColor=#666666;fontStyle=1;fontSize=11;" vertex="1" parent="1">
|
||||
<mxGeometry x="260" y="174" width="140" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="container_workflows" value="workflows\n(62 modules)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontStyle=1;fontSize=11;" vertex="1" parent="1">
|
||||
<mxGeometry x="385" y="97" width="140" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1000" value="112" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=5;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_aichat" target="container_datamodels">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1001" value="94" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=5;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_routes" target="container_datamodels">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1002" value="63" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=5;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_workflows" target="container_aichat">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1003" value="59" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=5;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_workflows" target="container_datamodels">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1004" value="46" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=5;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_aichat" target="container_shared">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1005" value="32" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=4;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_routes" target="container_auth">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1006" value="31" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=4;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_routes" target="container_shared">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1007" value="30" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=4;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_interfaces" target="container_datamodels">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1008" value="26" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=3;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_routes" target="container_interfaces">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1009" value="23" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=3;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_datamodels" target="container_shared">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1010" value="21" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=3;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_app" target="container_routes">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1011" value="18" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_workflows" target="container_shared">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1012" value="11" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_features_chatbot" target="container_datamodels">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1013" value="10" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_interfaces" target="container_connectors">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1014" value="9" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_auth" target="container_shared">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1015" value="9" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_features_chatbot" target="container_shared">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1016" value="9" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_services" target="container_shared">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1017" value="8" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_aichat" target="container_workflows">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1018" value="7" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_connectors" target="container_shared">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1019" value="7" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_interfaces" target="container_shared">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1020" value="7" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_interfaces" target="container_security">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1021" value="7" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_routes" target="container_services">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1022" value="7" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_routes" target="container_workflows">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1023" value="7" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_services" target="container_datamodels">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1024" value="6" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_auth" target="container_datamodels">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1025" value="6" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_security" target="container_datamodels">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1026" value="6" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_shared" target="container_datamodels">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1027" value="5" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_aichat" target="container_interfaces">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1028" value="5" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_features_chatbot" target="container_interfaces">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1029" value="5" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_features_realestate" target="container_datamodels">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1030" value="5" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_routes" target="container_aichat">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1031" value="4" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_aichat" target="container_security">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1032" value="4" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_auth" target="container_interfaces">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1033" value="4" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_auth" target="container_security">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1034" value="4" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_connectors" target="container_datamodels">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1035" value="4" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_features_realestate" target="container_shared">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1036" value="4" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_features_trustee" target="container_datamodels">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1037" value="4" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_services" target="container_interfaces">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1038" value="3" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_app" target="container_shared">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1039" value="3" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_features_realestate" target="container_connectors">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1040" value="3" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_features_trustee" target="container_interfaces">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1041" value="3" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_services" target="container_aichat">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1042" value="2" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_app" target="container_auth">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1043" value="2" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_app" target="container_features_featureRegistry">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1044" value="2" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_aichat" target="container_connectors">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1045" value="2" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_features_automation" target="container_aichat">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1046" value="2" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_features_chatbot" target="container_connectors">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1047" value="2" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_features_chatbot" target="container_security">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1048" value="2" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_features_chatbot" target="container_aichat">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1049" value="2" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_features_chatbot" target="container_workflows">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1050" value="2" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_features_neutralizer" target="container_shared">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1051" value="2" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_features_neutralizer" target="container_datamodels">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1052" value="2" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_features_neutralizer" target="container_services">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1053" value="2" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_features_realestate" target="container_security">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1054" value="2" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_features_trustee" target="container_shared">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1055" value="2" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_features_trustee" target="container_security">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1056" value="2" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_interfaces" target="container_aichat">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1057" value="2" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_security" target="container_connectors">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1058" value="2" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_workflows" target="container_services">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1059" value="1" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_app" target="container_interfaces">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1060" value="1" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_app" target="container_workflows">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1061" value="1" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_aichat" target="container_auth">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1062" value="1" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_datamodels" target="container_aichat">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1063" value="1" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_features_automation" target="container_auth">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1064" value="1" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_features_automation" target="container_datamodels">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1065" value="1" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_features_automation" target="container_services">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1066" value="1" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_features_automation" target="container_shared">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1067" value="1" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_features_automation" target="container_workflows">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1068" value="1" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_features_chatbot" target="container_services">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1069" value="1" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_features_chatbot" target="container_auth">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1070" value="1" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_features_neutralizer" target="container_interfaces">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1071" value="1" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_features_neutralizer" target="container_auth">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1072" value="1" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_features_realestate" target="container_interfaces">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1073" value="1" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_features_realestate" target="container_services">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1074" value="1" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_features_realestate" target="container_auth">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1075" value="1" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_features_trustee" target="container_connectors">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1076" value="1" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_features_trustee" target="container_auth">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1077" value="1" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_routes" target="container_security">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1078" value="1" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_routes" target="container_connectors">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1079" value="1" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_security" target="container_interfaces">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1080" value="1" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_security" target="container_shared">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1081" value="1" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_services" target="container_auth">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1082" value="1" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_shared" target="container_connectors">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1083" value="1" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_workflows" target="container_connectors">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="edge_1084" value="1" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=1;fontSize=10;labelBackgroundColor=#ffffff;fontStyle=1;" edge="1" parent="1" source="container_workflows" target="container_interfaces">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
Loading…
Reference in a new issue