serverside filter and sort for form generic
This commit is contained in:
parent
6c8c703115
commit
ac88f25526
3 changed files with 343 additions and 22 deletions
|
|
@ -62,6 +62,9 @@ def initBootstrap(db: DatabaseConnector) -> None:
|
||||||
# Initialize RBAC rules (uses roleIds from roles)
|
# Initialize RBAC rules (uses roleIds from roles)
|
||||||
initRbacRules(db)
|
initRbacRules(db)
|
||||||
|
|
||||||
|
# Initialize AccessRules for feature-template roles (idempotent - adds missing rules)
|
||||||
|
_initFeatureTemplateRoleAccessRules(db)
|
||||||
|
|
||||||
# Initialize admin user
|
# Initialize admin user
|
||||||
adminUserId = initAdminUser(db, mandateId)
|
adminUserId = initAdminUser(db, mandateId)
|
||||||
|
|
||||||
|
|
@ -498,6 +501,190 @@ def _getRoleId(db: DatabaseConnector, roleLabel: str) -> Optional[str]:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _initFeatureTemplateRoleAccessRules(db: DatabaseConnector) -> None:
|
||||||
|
"""
|
||||||
|
Initialize AccessRules for feature-template roles.
|
||||||
|
This is idempotent - only adds rules that don't exist yet.
|
||||||
|
|
||||||
|
Feature-template roles need explicit AccessRules for their respective tables:
|
||||||
|
- trustee-admin/accountant/client -> TrusteeOrganisation, TrusteeContract, etc.
|
||||||
|
- chatbot-admin/user -> ChatSession, etc.
|
||||||
|
- workflow-admin/editor/viewer -> ChatWorkflow, etc.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database connector instance
|
||||||
|
"""
|
||||||
|
logger.info("Checking feature-template role AccessRules")
|
||||||
|
|
||||||
|
# Define feature-specific table access
|
||||||
|
featureTableAccess = {
|
||||||
|
"trustee": {
|
||||||
|
"tables": [
|
||||||
|
"TrusteeOrganisation", "TrusteeRole", "TrusteeAccess",
|
||||||
|
"TrusteeContract", "TrusteeDocument", "TrusteePosition",
|
||||||
|
"TrusteePositionDocument"
|
||||||
|
],
|
||||||
|
"roles": {
|
||||||
|
"trustee-admin": {
|
||||||
|
"view": True,
|
||||||
|
"read": AccessLevel.ALL,
|
||||||
|
"create": AccessLevel.ALL,
|
||||||
|
"update": AccessLevel.ALL,
|
||||||
|
"delete": AccessLevel.ALL
|
||||||
|
},
|
||||||
|
"trustee-accountant": {
|
||||||
|
"view": True,
|
||||||
|
"read": AccessLevel.GROUP,
|
||||||
|
"create": AccessLevel.GROUP,
|
||||||
|
"update": AccessLevel.GROUP,
|
||||||
|
"delete": AccessLevel.NONE
|
||||||
|
},
|
||||||
|
"trustee-client": {
|
||||||
|
"view": True,
|
||||||
|
"read": AccessLevel.MY,
|
||||||
|
"create": AccessLevel.NONE,
|
||||||
|
"update": AccessLevel.NONE,
|
||||||
|
"delete": AccessLevel.NONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"chatbot": {
|
||||||
|
"tables": ["ChatSession", "ChatMessage"],
|
||||||
|
"roles": {
|
||||||
|
"chatbot-admin": {
|
||||||
|
"view": True,
|
||||||
|
"read": AccessLevel.ALL,
|
||||||
|
"create": AccessLevel.ALL,
|
||||||
|
"update": AccessLevel.ALL,
|
||||||
|
"delete": AccessLevel.ALL
|
||||||
|
},
|
||||||
|
"chatbot-user": {
|
||||||
|
"view": True,
|
||||||
|
"read": AccessLevel.MY,
|
||||||
|
"create": AccessLevel.MY,
|
||||||
|
"update": AccessLevel.MY,
|
||||||
|
"delete": AccessLevel.MY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"chatworkflow": {
|
||||||
|
"tables": ["ChatWorkflow", "AutomationDefinition"],
|
||||||
|
"roles": {
|
||||||
|
"workflow-admin": {
|
||||||
|
"view": True,
|
||||||
|
"read": AccessLevel.ALL,
|
||||||
|
"create": AccessLevel.ALL,
|
||||||
|
"update": AccessLevel.ALL,
|
||||||
|
"delete": AccessLevel.ALL
|
||||||
|
},
|
||||||
|
"workflow-editor": {
|
||||||
|
"view": True,
|
||||||
|
"read": AccessLevel.GROUP,
|
||||||
|
"create": AccessLevel.GROUP,
|
||||||
|
"update": AccessLevel.GROUP,
|
||||||
|
"delete": AccessLevel.NONE
|
||||||
|
},
|
||||||
|
"workflow-viewer": {
|
||||||
|
"view": True,
|
||||||
|
"read": AccessLevel.GROUP,
|
||||||
|
"create": AccessLevel.NONE,
|
||||||
|
"update": AccessLevel.NONE,
|
||||||
|
"delete": AccessLevel.NONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"neutralization": {
|
||||||
|
"tables": ["DataNeutraliserConfig", "DataNeutralizerAttributes"],
|
||||||
|
"roles": {
|
||||||
|
"neutralization-admin": {
|
||||||
|
"view": True,
|
||||||
|
"read": AccessLevel.ALL,
|
||||||
|
"create": AccessLevel.ALL,
|
||||||
|
"update": AccessLevel.ALL,
|
||||||
|
"delete": AccessLevel.ALL
|
||||||
|
},
|
||||||
|
"neutralization-analyst": {
|
||||||
|
"view": True,
|
||||||
|
"read": AccessLevel.GROUP,
|
||||||
|
"create": AccessLevel.NONE,
|
||||||
|
"update": AccessLevel.NONE,
|
||||||
|
"delete": AccessLevel.NONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"realestate": {
|
||||||
|
"tables": ["Projekt", "Parzelle", "Dokument", "Gemeinde", "Kanton", "Land"],
|
||||||
|
"roles": {
|
||||||
|
"realestate-admin": {
|
||||||
|
"view": True,
|
||||||
|
"read": AccessLevel.ALL,
|
||||||
|
"create": AccessLevel.ALL,
|
||||||
|
"update": AccessLevel.ALL,
|
||||||
|
"delete": AccessLevel.ALL
|
||||||
|
},
|
||||||
|
"realestate-manager": {
|
||||||
|
"view": True,
|
||||||
|
"read": AccessLevel.GROUP,
|
||||||
|
"create": AccessLevel.GROUP,
|
||||||
|
"update": AccessLevel.GROUP,
|
||||||
|
"delete": AccessLevel.NONE
|
||||||
|
},
|
||||||
|
"realestate-viewer": {
|
||||||
|
"view": True,
|
||||||
|
"read": AccessLevel.GROUP,
|
||||||
|
"create": AccessLevel.NONE,
|
||||||
|
"update": AccessLevel.NONE,
|
||||||
|
"delete": AccessLevel.NONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createdCount = 0
|
||||||
|
|
||||||
|
for featureCode, featureConfig in featureTableAccess.items():
|
||||||
|
tables = featureConfig["tables"]
|
||||||
|
roles = featureConfig["roles"]
|
||||||
|
|
||||||
|
for roleLabel, permissions in roles.items():
|
||||||
|
roleId = _getRoleId(db, roleLabel)
|
||||||
|
if not roleId:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for tableName in tables:
|
||||||
|
# Check if rule already exists
|
||||||
|
existingRules = db.getRecordset(
|
||||||
|
AccessRule,
|
||||||
|
recordFilter={
|
||||||
|
"roleId": roleId,
|
||||||
|
"context": AccessRuleContext.DATA,
|
||||||
|
"item": tableName
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if existingRules:
|
||||||
|
continue # Rule already exists
|
||||||
|
|
||||||
|
# Create new rule
|
||||||
|
rule = AccessRule(
|
||||||
|
roleId=roleId,
|
||||||
|
context=AccessRuleContext.DATA,
|
||||||
|
item=tableName,
|
||||||
|
view=permissions["view"],
|
||||||
|
read=permissions["read"],
|
||||||
|
create=permissions["create"],
|
||||||
|
update=permissions["update"],
|
||||||
|
delete=permissions["delete"]
|
||||||
|
)
|
||||||
|
db.recordCreate(AccessRule, rule)
|
||||||
|
createdCount += 1
|
||||||
|
|
||||||
|
if createdCount > 0:
|
||||||
|
logger.info(f"Created {createdCount} feature-template role AccessRules")
|
||||||
|
else:
|
||||||
|
logger.debug("All feature-template role AccessRules already exist")
|
||||||
|
|
||||||
|
|
||||||
def initRbacRules(db: DatabaseConnector) -> None:
|
def initRbacRules(db: DatabaseConnector) -> None:
|
||||||
"""
|
"""
|
||||||
Initialize RBAC rules if they don't exist.
|
Initialize RBAC rules if they don't exist.
|
||||||
|
|
|
||||||
|
|
@ -323,17 +323,21 @@ async def delete_mandate(
|
||||||
# User Management within Mandates (Mandate-Admin)
|
# User Management within Mandates (Mandate-Admin)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@router.get("/{targetMandateId}/users", response_model=List[MandateUserInfo])
|
@router.get("/{targetMandateId}/users")
|
||||||
@limiter.limit("60/minute")
|
@limiter.limit("60/minute")
|
||||||
async def listMandateUsers(
|
async def listMandateUsers(
|
||||||
request: Request,
|
request: Request,
|
||||||
targetMandateId: str = Path(..., description="ID of the mandate"),
|
targetMandateId: str = Path(..., description="ID of the mandate"),
|
||||||
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||||
context: RequestContext = Depends(getRequestContext)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> List[MandateUserInfo]:
|
):
|
||||||
"""
|
"""
|
||||||
List all users in a mandate.
|
List all users in a mandate with pagination, search, and sorting support.
|
||||||
|
|
||||||
Requires Mandate-Admin role or SysAdmin.
|
Requires Mandate-Admin role or SysAdmin.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pagination: Optional pagination parameters (page, pageSize, search, filters, sort)
|
||||||
"""
|
"""
|
||||||
# Check permission
|
# Check permission
|
||||||
if not _hasMandateAdminRole(context, targetMandateId) and not context.isSysAdmin:
|
if not _hasMandateAdminRole(context, targetMandateId) and not context.isSysAdmin:
|
||||||
|
|
@ -353,6 +357,26 @@ async def listMandateUsers(
|
||||||
detail=f"Mandate {targetMandateId} not found"
|
detail=f"Mandate {targetMandateId} not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Parse pagination parameter
|
||||||
|
paginationParams = None
|
||||||
|
if pagination:
|
||||||
|
try:
|
||||||
|
paginationDict = json.loads(pagination)
|
||||||
|
if paginationDict:
|
||||||
|
# Normalize pagination dict
|
||||||
|
if 'sort' in paginationDict and paginationDict['sort']:
|
||||||
|
normalizedSort = []
|
||||||
|
for item in paginationDict['sort']:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
normalizedSort.append(item)
|
||||||
|
paginationDict['sort'] = normalizedSort if normalizedSort else None
|
||||||
|
paginationParams = paginationDict
|
||||||
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid pagination parameter: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
# Get all UserMandate entries for this mandate
|
# Get all UserMandate entries for this mandate
|
||||||
userMandates = rootInterface.db.getRecordset(
|
userMandates = rootInterface.db.getRecordset(
|
||||||
UserMandate,
|
UserMandate,
|
||||||
|
|
@ -378,17 +402,77 @@ async def listMandateUsers(
|
||||||
else:
|
else:
|
||||||
roleLabels.append(roleId) # Fallback to ID if not found
|
roleLabels.append(roleId) # Fallback to ID if not found
|
||||||
|
|
||||||
result.append(MandateUserInfo(
|
result.append({
|
||||||
id=um.get("id"), # UserMandate ID as primary key
|
"id": um.get("id"), # UserMandate ID as primary key
|
||||||
userId=str(user.id),
|
"userId": str(user.id),
|
||||||
username=user.username,
|
"username": user.username,
|
||||||
email=user.email,
|
"email": user.email,
|
||||||
fullName=user.fullName,
|
"fullName": user.fullName,
|
||||||
roleIds=roleIds,
|
"roleIds": roleIds,
|
||||||
roleLabels=roleLabels,
|
"roleLabels": roleLabels,
|
||||||
enabled=um.get("enabled", True)
|
"enabled": um.get("enabled", True)
|
||||||
))
|
})
|
||||||
|
|
||||||
|
# Apply search, filtering, and sorting if pagination requested
|
||||||
|
if paginationParams:
|
||||||
|
# Apply search (if search term provided)
|
||||||
|
searchTerm = paginationParams.get('search', '').lower() if paginationParams.get('search') else ''
|
||||||
|
if searchTerm:
|
||||||
|
searchedResult = []
|
||||||
|
for item in result:
|
||||||
|
username = (item.get("username") or "").lower()
|
||||||
|
email = (item.get("email") or "").lower()
|
||||||
|
fullName = (item.get("fullName") or "").lower()
|
||||||
|
roleLabelsStr = " ".join(item.get("roleLabels") or []).lower()
|
||||||
|
|
||||||
|
if searchTerm in username or searchTerm in email or searchTerm in fullName or searchTerm in roleLabelsStr:
|
||||||
|
searchedResult.append(item)
|
||||||
|
result = searchedResult
|
||||||
|
|
||||||
|
# Apply filters (if filters provided)
|
||||||
|
filters = paginationParams.get('filters')
|
||||||
|
if filters:
|
||||||
|
for fieldName, filterValue in filters.items():
|
||||||
|
if filterValue is not None and filterValue != '':
|
||||||
|
filterValueLower = str(filterValue).lower()
|
||||||
|
result = [
|
||||||
|
item for item in result
|
||||||
|
if str(item.get(fieldName, '')).lower() == filterValueLower
|
||||||
|
]
|
||||||
|
|
||||||
|
# Apply sorting
|
||||||
|
sortFields = paginationParams.get('sort')
|
||||||
|
if sortFields:
|
||||||
|
for sortItem in reversed(sortFields):
|
||||||
|
field = sortItem.get('field')
|
||||||
|
direction = sortItem.get('direction', 'asc')
|
||||||
|
if field:
|
||||||
|
result = sorted(
|
||||||
|
result,
|
||||||
|
key=lambda x: str(x.get(field, '') or '').lower(),
|
||||||
|
reverse=(direction == 'desc')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply pagination
|
||||||
|
page = paginationParams.get('page', 1)
|
||||||
|
pageSize = paginationParams.get('pageSize', 25)
|
||||||
|
totalItems = len(result)
|
||||||
|
totalPages = (totalItems + pageSize - 1) // pageSize if totalItems > 0 else 0
|
||||||
|
startIdx = (page - 1) * pageSize
|
||||||
|
endIdx = startIdx + pageSize
|
||||||
|
paginatedResult = result[startIdx:endIdx]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": paginatedResult,
|
||||||
|
"pagination": {
|
||||||
|
"currentPage": page,
|
||||||
|
"pageSize": pageSize,
|
||||||
|
"totalItems": totalItems,
|
||||||
|
"totalPages": totalPages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# No pagination - return all users as list
|
||||||
return result
|
return result
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
|
||||||
|
|
@ -566,21 +566,25 @@ async def listRoles(
|
||||||
request: Request,
|
request: Request,
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||||
includeTemplates: bool = Query(False, description="Include feature template roles"),
|
includeTemplates: bool = Query(False, description="Include feature template roles"),
|
||||||
|
mandateId: Optional[str] = Query(None, description="Include mandate-specific roles for this mandate"),
|
||||||
|
scopeFilter: Optional[str] = Query(None, description="Filter by scope: 'all', 'mandate', 'global', 'system'"),
|
||||||
currentUser: User = Depends(requireSysAdmin)
|
currentUser: User = Depends(requireSysAdmin)
|
||||||
) -> PaginatedResponse:
|
) -> PaginatedResponse:
|
||||||
"""
|
"""
|
||||||
Get list of global/system roles with metadata.
|
Get list of roles with metadata.
|
||||||
MULTI-TENANT: SysAdmin-only (roles are system resources).
|
MULTI-TENANT: SysAdmin-only (roles are system resources).
|
||||||
|
|
||||||
By default, only returns true global roles (mandateId=None, featureInstanceId=None, featureCode=None).
|
By default, only returns true global roles (mandateId=None, featureInstanceId=None, featureCode=None).
|
||||||
Feature template roles are managed via /api/features/templates/roles.
|
Feature template roles are managed via /api/features/templates/roles.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
pagination: Optional pagination parameters
|
pagination: Optional pagination parameters (includes search, filters, sort)
|
||||||
includeTemplates: If True, also include feature template roles (featureCode != None)
|
includeTemplates: If True, also include feature template roles (featureCode != None)
|
||||||
|
mandateId: If provided, also include mandate-specific roles for this mandate
|
||||||
|
scopeFilter: Filter by scope type: 'all', 'mandate', 'global', 'system'
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
- List of role dictionaries with role label, description, and user count
|
- List of role dictionaries with role label, description, user count, and computed scopeType
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
interface = getRootInterface()
|
interface = getRootInterface()
|
||||||
|
|
@ -605,19 +609,45 @@ async def listRoles(
|
||||||
# Count role assignments from UserMandateRole table
|
# Count role assignments from UserMandateRole table
|
||||||
roleCounts = interface.countRoleAssignments()
|
roleCounts = interface.countRoleAssignments()
|
||||||
|
|
||||||
|
# Helper function to compute scopeType
|
||||||
|
def _computeScopeType(role) -> str:
|
||||||
|
if role.isSystemRole:
|
||||||
|
return "system"
|
||||||
|
if role.mandateId:
|
||||||
|
return "mandate"
|
||||||
|
return "global"
|
||||||
|
|
||||||
# Convert Role objects to dictionaries and add user counts
|
# Convert Role objects to dictionaries and add user counts
|
||||||
# Filter to only global roles (mandateId=None, featureInstanceId=None)
|
# Filter logic:
|
||||||
# Unless includeTemplates=True, also exclude feature template roles (featureCode != None)
|
# - Always include global roles (mandateId=None, featureInstanceId=None)
|
||||||
|
# - If mandateId provided, also include roles for that specific mandate
|
||||||
|
# - Unless includeTemplates=True, exclude feature template roles (featureCode != None)
|
||||||
result = []
|
result = []
|
||||||
for role in dbRoles:
|
for role in dbRoles:
|
||||||
# Filter: Only global roles (no mandate, no instance)
|
# Always exclude feature-instance level roles
|
||||||
if role.mandateId is not None or role.featureInstanceId is not None:
|
if role.featureInstanceId is not None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Include global roles (mandateId=None) OR mandate-specific roles if mandateId matches
|
||||||
|
if role.mandateId is not None and (mandateId is None or role.mandateId != mandateId):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Filter: Exclude feature template roles unless includeTemplates=True
|
# Filter: Exclude feature template roles unless includeTemplates=True
|
||||||
if not includeTemplates and role.featureCode is not None:
|
if not includeTemplates and role.featureCode is not None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Compute scopeType (system, global, mandate)
|
||||||
|
scopeType = _computeScopeType(role)
|
||||||
|
|
||||||
|
# Apply scopeFilter if provided
|
||||||
|
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({
|
result.append({
|
||||||
"id": role.id,
|
"id": role.id,
|
||||||
"roleLabel": role.roleLabel,
|
"roleLabel": role.roleLabel,
|
||||||
|
|
@ -626,11 +656,31 @@ async def listRoles(
|
||||||
"featureInstanceId": role.featureInstanceId,
|
"featureInstanceId": role.featureInstanceId,
|
||||||
"featureCode": role.featureCode,
|
"featureCode": role.featureCode,
|
||||||
"userCount": roleCounts.get(str(role.id), 0),
|
"userCount": roleCounts.get(str(role.id), 0),
|
||||||
"isSystemRole": role.isSystemRole
|
"isSystemRole": role.isSystemRole,
|
||||||
|
"scopeType": scopeType # Computed field for frontend display
|
||||||
})
|
})
|
||||||
|
|
||||||
# Apply filtering and sorting if pagination requested
|
# Apply search, filtering and sorting if pagination requested
|
||||||
if paginationParams:
|
if paginationParams:
|
||||||
|
# Apply search (if search term provided in filters)
|
||||||
|
searchTerm = paginationParams.filters.get("search", "").lower() if paginationParams.filters else ""
|
||||||
|
if searchTerm:
|
||||||
|
searchedResult = []
|
||||||
|
for item in result:
|
||||||
|
# Search in roleLabel and description
|
||||||
|
roleLabel = (item.get("roleLabel") or "").lower()
|
||||||
|
description = item.get("description")
|
||||||
|
descText = ""
|
||||||
|
if isinstance(description, dict):
|
||||||
|
descText = " ".join(str(v) for v in description.values()).lower()
|
||||||
|
elif description:
|
||||||
|
descText = str(description).lower()
|
||||||
|
scopeType = (item.get("scopeType") or "").lower()
|
||||||
|
|
||||||
|
if searchTerm in roleLabel or searchTerm in descText or searchTerm in scopeType:
|
||||||
|
searchedResult.append(item)
|
||||||
|
result = searchedResult
|
||||||
|
|
||||||
# Apply filtering (if filters provided)
|
# Apply filtering (if filters provided)
|
||||||
if paginationParams.filters:
|
if paginationParams.filters:
|
||||||
# Use the interface's filter method
|
# Use the interface's filter method
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue