diff --git a/modules/connectors/connectorDbPostgre.py b/modules/connectors/connectorDbPostgre.py index 83517f31..9675ffca 100644 --- a/modules/connectors/connectorDbPostgre.py +++ b/modules/connectors/connectorDbPostgre.py @@ -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]: diff --git a/modules/datamodels/datamodelPagination.py b/modules/datamodels/datamodelPagination.py index 9815027f..2719327b 100644 --- a/modules/datamodels/datamodelPagination.py +++ b/modules/datamodels/datamodelPagination.py @@ -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: diff --git a/modules/features/automation/interfaceFeatureAutomation.py b/modules/features/automation/interfaceFeatureAutomation.py index 3b20ca3d..3fc2420b 100644 --- a/modules/features/automation/interfaceFeatureAutomation.py +++ b/modules/features/automation/interfaceFeatureAutomation.py @@ -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: diff --git a/modules/features/automation/routeFeatureAutomation.py b/modules/features/automation/routeFeatureAutomation.py index 6cdc4d44..81f0852b 100644 --- a/modules/features/automation/routeFeatureAutomation.py +++ b/modules/features/automation/routeFeatureAutomation.py @@ -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 diff --git a/modules/features/chatbot/interfaceFeatureChatbot.py b/modules/features/chatbot/interfaceFeatureChatbot.py index 559a9187..4a03bec9 100644 --- a/modules/features/chatbot/interfaceFeatureChatbot.py +++ b/modules/features/chatbot/interfaceFeatureChatbot.py @@ -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 diff --git a/modules/features/realEstate/interfaceFeatureRealEstate.py b/modules/features/realEstate/interfaceFeatureRealEstate.py index 65601d9a..f7ed52b6 100644 --- a/modules/features/realEstate/interfaceFeatureRealEstate.py +++ b/modules/features/realEstate/interfaceFeatureRealEstate.py @@ -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.""" diff --git a/modules/features/realEstate/routeFeatureRealEstate.py b/modules/features/realEstate/routeFeatureRealEstate.py index b4df86dd..82fa55ba 100644 --- a/modules/features/realEstate/routeFeatureRealEstate.py +++ b/modules/features/realEstate/routeFeatureRealEstate.py @@ -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( diff --git a/modules/features/trustee/interfaceFeatureTrustee.py b/modules/features/trustee/interfaceFeatureTrustee.py index a4b13c27..b9a95005 100644 --- a/modules/features/trustee/interfaceFeatureTrustee.py +++ b/modules/features/trustee/interfaceFeatureTrustee.py @@ -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 ===== diff --git a/modules/features/trustee/routeFeatureTrustee.py b/modules/features/trustee/routeFeatureTrustee.py index feb873ae..13b28b07 100644 --- a/modules/features/trustee/routeFeatureTrustee.py +++ b/modules/features/trustee/routeFeatureTrustee.py @@ -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) diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py index 092fd589..12eb935b 100644 --- a/modules/interfaces/interfaceDbApp.py +++ b/modules/interfaces/interfaceDbApp.py @@ -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]: """ diff --git a/modules/interfaces/interfaceDbBilling.py b/modules/interfaces/interfaceDbBilling.py index 58d56895..2db71bb4 100644 --- a/modules/interfaces/interfaceDbBilling.py +++ b/modules/interfaces/interfaceDbBilling.py @@ -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] diff --git a/modules/interfaces/interfaceDbChat.py b/modules/interfaces/interfaceDbChat.py index f2a3d4c6..b0d4aff3 100644 --- a/modules/interfaces/interfaceDbChat.py +++ b/modules/interfaces/interfaceDbChat.py @@ -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 diff --git a/modules/interfaces/interfaceDbManagement.py b/modules/interfaces/interfaceDbManagement.py index 9c266aac..ae0b14b0 100644 --- a/modules/interfaces/interfaceDbManagement.py +++ b/modules/interfaces/interfaceDbManagement.py @@ -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]: diff --git a/modules/interfaces/interfaceRbac.py b/modules/interfaces/interfaceRbac.py index b4c9a3b4..b9a4ac9b 100644 --- a/modules/interfaces/interfaceRbac.py +++ b/modules/interfaces/interfaceRbac.py @@ -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, diff --git a/modules/routes/routeAdminAutomationEvents.py b/modules/routes/routeAdminAutomationEvents.py index d184ae76..47d3ac9c 100644 --- a/modules/routes/routeAdminAutomationEvents.py +++ b/modules/routes/routeAdminAutomationEvents.py @@ -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") diff --git a/modules/routes/routeAdminFeatures.py b/modules/routes/routeAdminFeatures.py index b31b5e4c..3e701548 100644 --- a/modules/routes/routeAdminFeatures.py +++ b/modules/routes/routeAdminFeatures.py @@ -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( diff --git a/modules/routes/routeAdminRbacRules.py b/modules/routes/routeAdminRbacRules.py index a7d4637e..3778d227 100644 --- a/modules/routes/routeAdminRbacRules.py +++ b/modules/routes/routeAdminRbacRules.py @@ -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( diff --git a/modules/routes/routeBilling.py b/modules/routes/routeBilling.py index e1c23b48..04412752 100644 --- a/modules/routes/routeBilling.py +++ b/modules/routes/routeBilling.py @@ -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)) diff --git a/modules/routes/routeDataConnections.py b/modules/routes/routeDataConnections.py index 2a6be738..17ef0115 100644 --- a/modules/routes/routeDataConnections.py +++ b/modules/routes/routeDataConnections.py @@ -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( diff --git a/modules/routes/routeDataFiles.py b/modules/routes/routeDataFiles.py index 470793bb..bb77ffc5 100644 --- a/modules/routes/routeDataFiles.py +++ b/modules/routes/routeDataFiles.py @@ -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( diff --git a/modules/routes/routeDataMandates.py b/modules/routes/routeDataMandates.py index 16b7166d..2c2bd31c 100644 --- a/modules/routes/routeDataMandates.py +++ b/modules/routes/routeDataMandates.py @@ -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( diff --git a/modules/routes/routeDataPrompts.py b/modules/routes/routeDataPrompts.py index faf692fe..f9246ab6 100644 --- a/modules/routes/routeDataPrompts.py +++ b/modules/routes/routeDataPrompts.py @@ -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( diff --git a/modules/routes/routeDataUsers.py b/modules/routes/routeDataUsers.py index 16742388..7e903466 100644 --- a/modules/routes/routeDataUsers.py +++ b/modules/routes/routeDataUsers.py @@ -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( diff --git a/modules/routes/routeInvitations.py b/modules/routes/routeInvitations.py index 906b11e7..cb913137 100644 --- a/modules/routes/routeInvitations.py +++ b/modules/routes/routeInvitations.py @@ -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( diff --git a/modules/routes/routeMessaging.py b/modules/routes/routeMessaging.py index 0b4784ff..42e15f0e 100644 --- a/modules/routes/routeMessaging.py +++ b/modules/routes/routeMessaging.py @@ -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( diff --git a/modules/routes/routeRealEstate.py b/modules/routes/routeRealEstate.py index 881a77dc..a3466aca 100644 --- a/modules/routes/routeRealEstate.py +++ b/modules/routes/routeRealEstate.py @@ -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) diff --git a/modules/routes/routeSubscription.py b/modules/routes/routeSubscription.py index 28ad7aa9..8334a8c0 100644 --- a/modules/routes/routeSubscription.py +++ b/modules/routes/routeSubscription.py @@ -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) diff --git a/modules/system/mainSystem.py b/modules/system/mainSystem.py index 58667645..bfa3e23f 100644 --- a/modules/system/mainSystem.py +++ b/modules/system/mainSystem.py @@ -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 ── diff --git a/scripts/.$import_diagram.drawio.bkp b/scripts/.$import_diagram.drawio.bkp deleted file mode 100644 index 588285b0..00000000 --- a/scripts/.$import_diagram.drawio.bkp +++ /dev/null @@ -1,2723 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/scripts/.$import_diagram_containers.drawio.bkp b/scripts/.$import_diagram_containers.drawio.bkp deleted file mode 100644 index cad95eef..00000000 --- a/scripts/.$import_diagram_containers.drawio.bkp +++ /dev/null @@ -1,317 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file