1574 lines
61 KiB
Python
1574 lines
61 KiB
Python
# Copyright (c) 2025 Patrick Motsch
|
|
# All rights reserved.
|
|
"""
|
|
Routes for Trustee feature data management.
|
|
Implements CRUD operations for organisations, roles, access, contracts, documents, and positions.
|
|
|
|
URL Structure: /api/trustee/{instanceId}/{entity}
|
|
- instanceId is the FeatureInstance ID (required for all operations)
|
|
- This ensures proper multi-tenant isolation at the URL level
|
|
"""
|
|
|
|
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Query, Response, UploadFile, File, Form
|
|
from fastapi.responses import StreamingResponse
|
|
from typing import List, Dict, Any, Optional
|
|
from fastapi import status
|
|
import logging
|
|
import json
|
|
import io
|
|
import base64
|
|
|
|
from modules.auth import limiter, getRequestContext, RequestContext
|
|
from .interfaceFeatureTrustee import getInterface
|
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
|
from modules.interfaces.interfaceFeatures import getFeatureInterface
|
|
from .datamodelFeatureTrustee import (
|
|
TrusteeOrganisation,
|
|
TrusteeRole,
|
|
TrusteeAccess,
|
|
TrusteeContract,
|
|
TrusteeDocument,
|
|
TrusteePosition,
|
|
TrusteePositionDocument,
|
|
)
|
|
from modules.datamodels.datamodelPagination import (
|
|
PaginationParams,
|
|
PaginatedResponse,
|
|
PaginationMetadata,
|
|
normalize_pagination_dict,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(
|
|
prefix="/api/trustee",
|
|
tags=["Trustee"],
|
|
responses={404: {"description": "Not found"}}
|
|
)
|
|
|
|
|
|
# ===== Helper Functions =====
|
|
|
|
def _parsePagination(pagination: Optional[str]) -> Optional[PaginationParams]:
|
|
"""Parse pagination parameter from JSON string."""
|
|
if not pagination:
|
|
return None
|
|
try:
|
|
paginationDict = json.loads(pagination)
|
|
if paginationDict:
|
|
paginationDict = normalize_pagination_dict(paginationDict)
|
|
return PaginationParams(**paginationDict)
|
|
except (json.JSONDecodeError, ValueError) as e:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Invalid pagination parameter: {str(e)}"
|
|
)
|
|
return None
|
|
|
|
|
|
async def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str:
|
|
"""
|
|
Validate that the user has access to the feature instance.
|
|
Returns the mandateId for the instance.
|
|
|
|
Args:
|
|
instanceId: The FeatureInstance ID from URL
|
|
context: The request context with user info
|
|
|
|
Returns:
|
|
mandateId of the instance
|
|
|
|
Raises:
|
|
HTTPException 404 if instance not found
|
|
HTTPException 403 if user doesn't have access
|
|
"""
|
|
rootInterface = getRootInterface()
|
|
featureInterface = getFeatureInterface(rootInterface.db)
|
|
|
|
instance = featureInterface.getFeatureInstance(instanceId)
|
|
if not instance:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"Feature instance '{instanceId}' not found"
|
|
)
|
|
|
|
# Verify it's a trustee instance
|
|
if instance.featureCode != "trustee":
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Instance '{instanceId}' is not a trustee instance"
|
|
)
|
|
|
|
# Verify user has access to this instance
|
|
if not context.isSysAdmin:
|
|
# Check if user has FeatureAccess for this instance
|
|
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
|
|
hasAccess = any(
|
|
str(fa.featureInstanceId) == instanceId and fa.enabled
|
|
for fa in featureAccesses
|
|
)
|
|
if not hasAccess:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail=f"Access denied to feature instance '{instanceId}'"
|
|
)
|
|
|
|
return str(instance.mandateId)
|
|
|
|
|
|
# ============================================================================
|
|
# ATTRIBUTES ENDPOINT (for FormGeneratorTable)
|
|
# ============================================================================
|
|
|
|
# Mapping of entity names to Pydantic model classes
|
|
_TRUSTEE_ENTITY_MODELS = {
|
|
"TrusteeOrganisation": TrusteeOrganisation,
|
|
"TrusteeRole": TrusteeRole,
|
|
"TrusteeAccess": TrusteeAccess,
|
|
"TrusteeContract": TrusteeContract,
|
|
"TrusteeDocument": TrusteeDocument,
|
|
"TrusteePosition": TrusteePosition,
|
|
"TrusteePositionDocument": TrusteePositionDocument,
|
|
}
|
|
|
|
|
|
@router.get("/{instanceId}/attributes/{entityType}")
|
|
@limiter.limit("30/minute")
|
|
async def get_entity_attributes(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
entityType: str = Path(..., description="Entity type (e.g., TrusteeDocument)"),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Get attribute definitions for a Trustee entity.
|
|
Used by FormGeneratorTable for dynamic column generation.
|
|
"""
|
|
# Validate instance access
|
|
await _validateInstanceAccess(instanceId, context)
|
|
|
|
# Check if entity type is valid
|
|
if entityType not in _TRUSTEE_ENTITY_MODELS:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"Unknown entity type: {entityType}. Valid types: {list(_TRUSTEE_ENTITY_MODELS.keys())}"
|
|
)
|
|
|
|
# Get the model class
|
|
modelClass = _TRUSTEE_ENTITY_MODELS[entityType]
|
|
|
|
# Import the attribute utils
|
|
from modules.shared.attributeUtils import getModelAttributeDefinitions
|
|
|
|
try:
|
|
attrDefs = getModelAttributeDefinitions(modelClass)
|
|
# Filter to only visible attributes
|
|
visibleAttrs = [
|
|
attr for attr in attrDefs.get("attributes", [])
|
|
if isinstance(attr, dict) and attr.get("visible", True)
|
|
]
|
|
return {"attributes": visibleAttrs}
|
|
except Exception as e:
|
|
logger.error(f"Error getting attributes for {entityType}: {e}")
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Error getting attributes for {entityType}: {str(e)}"
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# OPTIONS ENDPOINTS (for dropdowns)
|
|
# ============================================================================
|
|
|
|
@router.get("/mime-types/options", response_model=List[Dict[str, Any]])
|
|
@limiter.limit("60/minute")
|
|
async def get_mime_type_options(
|
|
request: Request,
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> List[Dict[str, Any]]:
|
|
"""Get supported MIME types from the document extraction service.
|
|
Returns: [{ value: "mime/type", label: "Description" }]
|
|
"""
|
|
from modules.services.serviceExtraction.subRegistry import ExtractorRegistry
|
|
|
|
registry = ExtractorRegistry()
|
|
formats = registry.getSupportedFormats()
|
|
|
|
# Collect all unique MIME types
|
|
allMimeTypes = set()
|
|
for mimeList in formats.get("mime_types", {}).values():
|
|
allMimeTypes.update(mimeList)
|
|
|
|
# Sort and create options with labels
|
|
result = []
|
|
for mimeType in sorted(allMimeTypes):
|
|
# Create readable label from mime type
|
|
parts = mimeType.split("/")
|
|
if len(parts) == 2:
|
|
mainType, subType = parts
|
|
# Clean up subtype for label
|
|
label = subType.replace("vnd.", "").replace("x-", "").replace("-", " ").title()
|
|
result.append({"value": mimeType, "label": f"{label} ({mimeType})"})
|
|
else:
|
|
result.append({"value": mimeType, "label": mimeType})
|
|
|
|
return result
|
|
|
|
|
|
@router.get("/{instanceId}/organisations/options", response_model=List[Dict[str, Any]])
|
|
@limiter.limit("60/minute")
|
|
async def get_organisation_options(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> List[Dict[str, Any]]:
|
|
"""Get organisation options for select dropdowns. Returns: [{ value, label }]"""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
result = interface.getAllOrganisations(None)
|
|
items = result.items if hasattr(result, 'items') else result
|
|
return [{"value": org["id"], "label": org.get("label") or org["id"]} for org in items]
|
|
|
|
|
|
@router.get("/{instanceId}/roles/options", response_model=List[Dict[str, Any]])
|
|
@limiter.limit("60/minute")
|
|
async def get_role_options(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> List[Dict[str, Any]]:
|
|
"""Get role options for select dropdowns. Returns: [{ value, label }]"""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
result = interface.getAllRoles(None)
|
|
items = result.items if hasattr(result, 'items') else result
|
|
return [{"value": role["id"], "label": role.get("desc") or role["id"]} for role in items]
|
|
|
|
|
|
@router.get("/{instanceId}/contracts/options", response_model=List[Dict[str, Any]])
|
|
@limiter.limit("60/minute")
|
|
async def get_contract_options(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
organisationId: Optional[str] = Query(None, description="Optional: Filter by organisation ID"),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> List[Dict[str, Any]]:
|
|
"""
|
|
Get contract options for select dropdowns.
|
|
|
|
Optionally filter by organisationId to get only contracts for a specific organisation.
|
|
This is useful for dependent dropdowns in forms.
|
|
|
|
Returns: [{ value, label }]
|
|
"""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
|
|
if organisationId:
|
|
# Gefiltert nach Organisation
|
|
items = interface.getContractsByOrganisation(organisationId)
|
|
else:
|
|
# Alle Contracts
|
|
result = interface.getAllContracts(None)
|
|
items = result.items if hasattr(result, 'items') else result
|
|
|
|
return [{"value": c.id, "label": c.label or c.name or c.id} for c in items]
|
|
|
|
|
|
@router.get("/{instanceId}/documents/options", response_model=List[Dict[str, Any]])
|
|
@limiter.limit("60/minute")
|
|
async def get_document_options(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> List[Dict[str, Any]]:
|
|
"""Get document options for select dropdowns. Returns: [{ id, value, label }]"""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
result = interface.getAllDocuments(None)
|
|
items = result.items if hasattr(result, 'items') else result
|
|
# Include 'id' for FK resolution in tables
|
|
return [{"id": d.id, "value": d.id, "label": d.documentName or d.id} for d in items]
|
|
|
|
|
|
@router.get("/{instanceId}/positions/options", response_model=List[Dict[str, Any]])
|
|
@limiter.limit("60/minute")
|
|
async def get_position_options(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> List[Dict[str, Any]]:
|
|
"""Get position options for select dropdowns. Returns: [{ id, value, label }]"""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
result = interface.getAllPositions(None)
|
|
items = result.items if hasattr(result, 'items') else result
|
|
|
|
def _makePositionLabel(p: TrusteePosition) -> str:
|
|
parts = []
|
|
if p.valuta:
|
|
parts.append(str(p.valuta)[:10]) # Datum ohne Zeit
|
|
if p.company:
|
|
parts.append(p.company[:30])
|
|
if p.desc:
|
|
parts.append(p.desc[:30])
|
|
return " - ".join(parts) if parts else p.id
|
|
|
|
# Include 'id' for FK resolution in tables
|
|
return [{"id": p.id, "value": p.id, "label": _makePositionLabel(p)} for p in items]
|
|
|
|
|
|
# ============================================================================
|
|
# CRUD ENDPOINTS
|
|
# ============================================================================
|
|
|
|
# ===== Organisation Routes =====
|
|
|
|
@router.get("/{instanceId}/organisations", response_model=PaginatedResponse[TrusteeOrganisation])
|
|
@limiter.limit("30/minute")
|
|
async def get_organisations(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> PaginatedResponse[TrusteeOrganisation]:
|
|
"""Get all organisations for a feature instance with optional pagination."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
paginationParams = _parsePagination(pagination)
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
result = interface.getAllOrganisations(paginationParams)
|
|
|
|
if paginationParams:
|
|
return PaginatedResponse(
|
|
items=result.items,
|
|
pagination=PaginationMetadata(
|
|
currentPage=paginationParams.page or 1,
|
|
pageSize=paginationParams.pageSize or 20,
|
|
totalItems=result.totalItems,
|
|
totalPages=result.totalPages,
|
|
sort=paginationParams.sort if paginationParams else [],
|
|
filters=paginationParams.filters if paginationParams else None
|
|
)
|
|
)
|
|
return PaginatedResponse(items=result.items, pagination=None)
|
|
|
|
|
|
@router.get("/{instanceId}/organisations/{orgId}", response_model=TrusteeOrganisation)
|
|
@limiter.limit("30/minute")
|
|
async def get_organisation(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
orgId: str = Path(..., description="Organisation ID"),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> TrusteeOrganisation:
|
|
"""Get a single organisation by ID."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
org = interface.getOrganisation(orgId)
|
|
if not org:
|
|
raise HTTPException(status_code=404, detail=f"Organisation {orgId} not found")
|
|
return org
|
|
|
|
|
|
@router.post("/{instanceId}/organisations", response_model=TrusteeOrganisation, status_code=201)
|
|
@limiter.limit("10/minute")
|
|
async def create_organisation(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
data: TrusteeOrganisation = Body(...),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> TrusteeOrganisation:
|
|
"""Create a new organisation."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
result = interface.createOrganisation(data.model_dump())
|
|
if not result:
|
|
raise HTTPException(status_code=400, detail="Failed to create organisation")
|
|
return result
|
|
|
|
|
|
@router.put("/{instanceId}/organisations/{orgId}", response_model=TrusteeOrganisation)
|
|
@limiter.limit("10/minute")
|
|
async def update_organisation(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
orgId: str = Path(..., description="Organisation ID"),
|
|
data: TrusteeOrganisation = Body(...),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> TrusteeOrganisation:
|
|
"""Update an organisation."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
existing = interface.getOrganisation(orgId)
|
|
if not existing:
|
|
raise HTTPException(status_code=404, detail=f"Organisation {orgId} not found")
|
|
|
|
result = interface.updateOrganisation(orgId, data.model_dump(exclude={"id"}))
|
|
if not result:
|
|
raise HTTPException(status_code=400, detail="Failed to update organisation")
|
|
return result
|
|
|
|
|
|
@router.delete("/{instanceId}/organisations/{orgId}")
|
|
@limiter.limit("10/minute")
|
|
async def delete_organisation(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
orgId: str = Path(..., description="Organisation ID"),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> Dict[str, Any]:
|
|
"""Delete an organisation."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
existing = interface.getOrganisation(orgId)
|
|
if not existing:
|
|
raise HTTPException(status_code=404, detail=f"Organisation {orgId} not found")
|
|
|
|
success = interface.deleteOrganisation(orgId)
|
|
if not success:
|
|
raise HTTPException(status_code=400, detail="Failed to delete organisation")
|
|
return {"message": f"Organisation {orgId} deleted"}
|
|
|
|
|
|
# ===== Role Routes =====
|
|
|
|
@router.get("/{instanceId}/roles", response_model=PaginatedResponse[TrusteeRole])
|
|
@limiter.limit("30/minute")
|
|
async def get_roles(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
pagination: Optional[str] = Query(None),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> PaginatedResponse[TrusteeRole]:
|
|
"""Get all roles with optional pagination."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
paginationParams = _parsePagination(pagination)
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
result = interface.getAllRoles(paginationParams)
|
|
|
|
if paginationParams:
|
|
return PaginatedResponse(
|
|
items=result.items,
|
|
pagination=PaginationMetadata(
|
|
currentPage=paginationParams.page or 1,
|
|
pageSize=paginationParams.pageSize or 20,
|
|
totalItems=result.totalItems,
|
|
totalPages=result.totalPages,
|
|
sort=paginationParams.sort if paginationParams else [],
|
|
filters=paginationParams.filters if paginationParams else None
|
|
)
|
|
)
|
|
return PaginatedResponse(items=result.items, pagination=None)
|
|
|
|
|
|
@router.get("/{instanceId}/roles/{roleId}", response_model=TrusteeRole)
|
|
@limiter.limit("30/minute")
|
|
async def get_role(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
roleId: str = Path(..., description="Role ID"),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> TrusteeRole:
|
|
"""Get a single role by ID."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
role = interface.getRole(roleId)
|
|
if not role:
|
|
raise HTTPException(status_code=404, detail=f"Role {roleId} not found")
|
|
return role
|
|
|
|
|
|
@router.post("/{instanceId}/roles", response_model=TrusteeRole, status_code=201)
|
|
@limiter.limit("10/minute")
|
|
async def create_role(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
data: TrusteeRole = Body(...),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> TrusteeRole:
|
|
"""Create a new role (sysadmin only)."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
result = interface.createRole(data.model_dump())
|
|
if not result:
|
|
raise HTTPException(status_code=400, detail="Failed to create role")
|
|
return result
|
|
|
|
|
|
@router.put("/{instanceId}/roles/{roleId}", response_model=TrusteeRole)
|
|
@limiter.limit("10/minute")
|
|
async def update_role(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
roleId: str = Path(...),
|
|
data: TrusteeRole = Body(...),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> TrusteeRole:
|
|
"""Update a role (sysadmin only)."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
existing = interface.getRole(roleId)
|
|
if not existing:
|
|
raise HTTPException(status_code=404, detail=f"Role {roleId} not found")
|
|
|
|
result = interface.updateRole(roleId, data.model_dump(exclude={"id"}))
|
|
if not result:
|
|
raise HTTPException(status_code=400, detail="Failed to update role")
|
|
return result
|
|
|
|
|
|
@router.delete("/{instanceId}/roles/{roleId}")
|
|
@limiter.limit("10/minute")
|
|
async def delete_role(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
roleId: str = Path(...),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> Dict[str, Any]:
|
|
"""Delete a role (sysadmin only, fails if in use)."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
existing = interface.getRole(roleId)
|
|
if not existing:
|
|
raise HTTPException(status_code=404, detail=f"Role {roleId} not found")
|
|
|
|
success = interface.deleteRole(roleId)
|
|
if not success:
|
|
raise HTTPException(status_code=400, detail="Failed to delete role (may be in use)")
|
|
return {"message": f"Role {roleId} deleted"}
|
|
|
|
|
|
# ===== Access Routes =====
|
|
|
|
@router.get("/{instanceId}/access", response_model=PaginatedResponse[TrusteeAccess])
|
|
@limiter.limit("30/minute")
|
|
async def get_all_access(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
pagination: Optional[str] = Query(None),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> PaginatedResponse[TrusteeAccess]:
|
|
"""Get all access records with optional pagination."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
paginationParams = _parsePagination(pagination)
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
result = interface.getAllAccess(paginationParams)
|
|
|
|
if paginationParams:
|
|
return PaginatedResponse(
|
|
items=result.items,
|
|
pagination=PaginationMetadata(
|
|
currentPage=paginationParams.page or 1,
|
|
pageSize=paginationParams.pageSize or 20,
|
|
totalItems=result.totalItems,
|
|
totalPages=result.totalPages,
|
|
sort=paginationParams.sort if paginationParams else [],
|
|
filters=paginationParams.filters if paginationParams else None
|
|
)
|
|
)
|
|
return PaginatedResponse(items=result.items, pagination=None)
|
|
|
|
|
|
@router.get("/{instanceId}/access/{accessId}", response_model=TrusteeAccess)
|
|
@limiter.limit("30/minute")
|
|
async def get_access(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
accessId: str = Path(...),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> TrusteeAccess:
|
|
"""Get a single access record by ID."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
access = interface.getAccess(accessId)
|
|
if not access:
|
|
raise HTTPException(status_code=404, detail=f"Access {accessId} not found")
|
|
return access
|
|
|
|
|
|
@router.get("/{instanceId}/access/organisation/{orgId}", response_model=List[TrusteeAccess])
|
|
@limiter.limit("30/minute")
|
|
async def get_access_by_organisation(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
orgId: str = Path(...),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> List[TrusteeAccess]:
|
|
"""Get all access records for an organisation."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
return interface.getAccessByOrganisation(orgId)
|
|
|
|
|
|
@router.get("/{instanceId}/access/user/{userId}", response_model=List[TrusteeAccess])
|
|
@limiter.limit("30/minute")
|
|
async def get_access_by_user(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
userId: str = Path(...),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> List[TrusteeAccess]:
|
|
"""Get all access records for a user."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
return interface.getAccessByUser(userId)
|
|
|
|
|
|
@router.post("/{instanceId}/access", response_model=TrusteeAccess, status_code=201)
|
|
@limiter.limit("10/minute")
|
|
async def create_access(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
data: TrusteeAccess = Body(...),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> TrusteeAccess:
|
|
"""Create a new access record."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
result = interface.createAccess(data.model_dump())
|
|
if not result:
|
|
raise HTTPException(status_code=400, detail="Failed to create access")
|
|
return result
|
|
|
|
|
|
@router.put("/{instanceId}/access/{accessId}", response_model=TrusteeAccess)
|
|
@limiter.limit("10/minute")
|
|
async def update_access(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
accessId: str = Path(...),
|
|
data: TrusteeAccess = Body(...),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> TrusteeAccess:
|
|
"""Update an access record."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
existing = interface.getAccess(accessId)
|
|
if not existing:
|
|
raise HTTPException(status_code=404, detail=f"Access {accessId} not found")
|
|
|
|
result = interface.updateAccess(accessId, data.model_dump(exclude={"id"}))
|
|
if not result:
|
|
raise HTTPException(status_code=400, detail="Failed to update access")
|
|
return result
|
|
|
|
|
|
@router.delete("/{instanceId}/access/{accessId}")
|
|
@limiter.limit("10/minute")
|
|
async def delete_access(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
accessId: str = Path(...),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> Dict[str, Any]:
|
|
"""Delete an access record."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
existing = interface.getAccess(accessId)
|
|
if not existing:
|
|
raise HTTPException(status_code=404, detail=f"Access {accessId} not found")
|
|
|
|
success = interface.deleteAccess(accessId)
|
|
if not success:
|
|
raise HTTPException(status_code=400, detail="Failed to delete access")
|
|
return {"message": f"Access {accessId} deleted"}
|
|
|
|
|
|
# ===== Contract Routes =====
|
|
|
|
@router.get("/{instanceId}/contracts", response_model=PaginatedResponse[TrusteeContract])
|
|
@limiter.limit("30/minute")
|
|
async def get_contracts(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
pagination: Optional[str] = Query(None),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> PaginatedResponse[TrusteeContract]:
|
|
"""Get all contracts with optional pagination."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
paginationParams = _parsePagination(pagination)
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
result = interface.getAllContracts(paginationParams)
|
|
|
|
if paginationParams:
|
|
return PaginatedResponse(
|
|
items=result.items,
|
|
pagination=PaginationMetadata(
|
|
currentPage=paginationParams.page or 1,
|
|
pageSize=paginationParams.pageSize or 20,
|
|
totalItems=result.totalItems,
|
|
totalPages=result.totalPages,
|
|
sort=paginationParams.sort if paginationParams else [],
|
|
filters=paginationParams.filters if paginationParams else None
|
|
)
|
|
)
|
|
return PaginatedResponse(items=result.items, pagination=None)
|
|
|
|
|
|
@router.get("/{instanceId}/contracts/{contractId}", response_model=TrusteeContract)
|
|
@limiter.limit("30/minute")
|
|
async def get_contract(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
contractId: str = Path(...),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> TrusteeContract:
|
|
"""Get a single contract by ID."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
contract = interface.getContract(contractId)
|
|
if not contract:
|
|
raise HTTPException(status_code=404, detail=f"Contract {contractId} not found")
|
|
return contract
|
|
|
|
|
|
@router.get("/{instanceId}/contracts/organisation/{orgId}", response_model=List[TrusteeContract])
|
|
@limiter.limit("30/minute")
|
|
async def get_contracts_by_organisation(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
orgId: str = Path(...),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> List[TrusteeContract]:
|
|
"""Get all contracts for an organisation."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
return interface.getContractsByOrganisation(orgId)
|
|
|
|
|
|
@router.post("/{instanceId}/contracts", response_model=TrusteeContract, status_code=201)
|
|
@limiter.limit("10/minute")
|
|
async def create_contract(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
data: TrusteeContract = Body(...),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> TrusteeContract:
|
|
"""Create a new contract."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
result = interface.createContract(data.model_dump())
|
|
if not result:
|
|
raise HTTPException(status_code=400, detail="Failed to create contract")
|
|
return result
|
|
|
|
|
|
@router.put("/{instanceId}/contracts/{contractId}", response_model=TrusteeContract)
|
|
@limiter.limit("10/minute")
|
|
async def update_contract(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
contractId: str = Path(...),
|
|
data: TrusteeContract = Body(...),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> TrusteeContract:
|
|
"""Update a contract (organisationId is immutable)."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
existing = interface.getContract(contractId)
|
|
if not existing:
|
|
raise HTTPException(status_code=404, detail=f"Contract {contractId} not found")
|
|
|
|
result = interface.updateContract(contractId, data.model_dump(exclude={"id"}))
|
|
if not result:
|
|
raise HTTPException(status_code=400, detail="Failed to update contract (organisationId cannot be changed)")
|
|
return result
|
|
|
|
|
|
@router.delete("/{instanceId}/contracts/{contractId}")
|
|
@limiter.limit("10/minute")
|
|
async def delete_contract(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
contractId: str = Path(...),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> Dict[str, Any]:
|
|
"""Delete a contract."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
existing = interface.getContract(contractId)
|
|
if not existing:
|
|
raise HTTPException(status_code=404, detail=f"Contract {contractId} not found")
|
|
|
|
success = interface.deleteContract(contractId)
|
|
if not success:
|
|
raise HTTPException(status_code=400, detail="Failed to delete contract")
|
|
return {"message": f"Contract {contractId} deleted"}
|
|
|
|
|
|
# ===== Document Routes =====
|
|
|
|
@router.get("/{instanceId}/documents", response_model=PaginatedResponse[TrusteeDocument])
|
|
@limiter.limit("30/minute")
|
|
async def get_documents(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
pagination: Optional[str] = Query(None),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> PaginatedResponse[TrusteeDocument]:
|
|
"""Get all documents (metadata only) with optional pagination."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
paginationParams = _parsePagination(pagination)
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
result = interface.getAllDocuments(paginationParams)
|
|
|
|
if paginationParams:
|
|
return PaginatedResponse(
|
|
items=result.items,
|
|
pagination=PaginationMetadata(
|
|
currentPage=paginationParams.page or 1,
|
|
pageSize=paginationParams.pageSize or 20,
|
|
totalItems=result.totalItems,
|
|
totalPages=result.totalPages,
|
|
sort=paginationParams.sort if paginationParams else [],
|
|
filters=paginationParams.filters if paginationParams else None
|
|
)
|
|
)
|
|
return PaginatedResponse(items=result.items, pagination=None)
|
|
|
|
|
|
@router.get("/{instanceId}/documents/{documentId}", response_model=TrusteeDocument)
|
|
@limiter.limit("30/minute")
|
|
async def get_document(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
documentId: str = Path(...),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> TrusteeDocument:
|
|
"""Get document metadata by ID."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
doc = interface.getDocument(documentId)
|
|
if not doc:
|
|
raise HTTPException(status_code=404, detail=f"Document {documentId} not found")
|
|
return doc
|
|
|
|
|
|
@router.get("/{instanceId}/documents/{documentId}/data")
|
|
@limiter.limit("10/minute")
|
|
async def get_document_data(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
documentId: str = Path(...),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
):
|
|
"""Download document binary data."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
doc = interface.getDocument(documentId)
|
|
if not doc:
|
|
raise HTTPException(status_code=404, detail=f"Document {documentId} not found")
|
|
|
|
data = interface.getDocumentData(documentId)
|
|
if not data:
|
|
raise HTTPException(status_code=404, detail="Document data not found")
|
|
|
|
return StreamingResponse(
|
|
io.BytesIO(data),
|
|
media_type=doc.documentMimeType or "application/octet-stream",
|
|
headers={"Content-Disposition": f"attachment; filename={doc.documentName or 'document'}"}
|
|
)
|
|
|
|
|
|
@router.get("/{instanceId}/documents/contract/{contractId}", response_model=List[TrusteeDocument])
|
|
@limiter.limit("30/minute")
|
|
async def get_documents_by_contract(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
contractId: str = Path(...),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> List[TrusteeDocument]:
|
|
"""Get all documents for a contract."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
return interface.getDocumentsByContract(contractId)
|
|
|
|
|
|
@router.post("/{instanceId}/documents", response_model=TrusteeDocument, status_code=201)
|
|
@limiter.limit("10/minute")
|
|
async def create_document(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> TrusteeDocument:
|
|
"""Create a new document. Accepts JSON body with optional base64-encoded documentData."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
# Parse JSON body
|
|
body = await request.json()
|
|
|
|
# Handle documentData: convert base64 string to bytes if present
|
|
if "documentData" in body and body["documentData"]:
|
|
dataValue = body["documentData"]
|
|
if isinstance(dataValue, str):
|
|
# Base64-encoded data from frontend
|
|
try:
|
|
body["documentData"] = base64.b64decode(dataValue)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to decode base64 documentData: {e}")
|
|
body["documentData"] = None
|
|
elif isinstance(dataValue, bytes):
|
|
# Already bytes
|
|
pass
|
|
else:
|
|
# Unknown format (e.g., File object serialized wrong)
|
|
body["documentData"] = None
|
|
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
result = interface.createDocument(body)
|
|
if not result:
|
|
raise HTTPException(status_code=400, detail="Failed to create document")
|
|
return result
|
|
|
|
|
|
@router.post("/{instanceId}/documents/upload", response_model=TrusteeDocument, status_code=201)
|
|
@limiter.limit("10/minute")
|
|
async def upload_document(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
file: UploadFile = File(..., description="Document file"),
|
|
documentName: str = Form(..., description="Document name"),
|
|
documentMimeType: str = Form(default="application/octet-stream", description="MIME type"),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> TrusteeDocument:
|
|
"""Upload a document with multipart/form-data."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
# Read file content
|
|
fileContent = await file.read()
|
|
|
|
# Build document data
|
|
docData = {
|
|
"documentName": documentName,
|
|
"documentMimeType": documentMimeType or file.content_type or "application/octet-stream",
|
|
"documentData": fileContent
|
|
}
|
|
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
result = interface.createDocument(docData)
|
|
if not result:
|
|
raise HTTPException(status_code=400, detail="Failed to create document")
|
|
return result
|
|
|
|
|
|
@router.put("/{instanceId}/documents/{documentId}", response_model=TrusteeDocument)
|
|
@limiter.limit("10/minute")
|
|
async def update_document(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
documentId: str = Path(...),
|
|
data: TrusteeDocument = Body(...),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> TrusteeDocument:
|
|
"""Update document metadata."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
existing = interface.getDocument(documentId)
|
|
if not existing:
|
|
raise HTTPException(status_code=404, detail=f"Document {documentId} not found")
|
|
|
|
result = interface.updateDocument(documentId, data.model_dump(exclude={"id"}))
|
|
if not result:
|
|
raise HTTPException(status_code=400, detail="Failed to update document")
|
|
return result
|
|
|
|
|
|
@router.delete("/{instanceId}/documents/{documentId}")
|
|
@limiter.limit("10/minute")
|
|
async def delete_document(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
documentId: str = Path(...),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> Dict[str, Any]:
|
|
"""Delete a document."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
existing = interface.getDocument(documentId)
|
|
if not existing:
|
|
raise HTTPException(status_code=404, detail=f"Document {documentId} not found")
|
|
|
|
success = interface.deleteDocument(documentId)
|
|
if not success:
|
|
raise HTTPException(status_code=400, detail="Failed to delete document")
|
|
return {"message": f"Document {documentId} deleted"}
|
|
|
|
|
|
# ===== Position Routes =====
|
|
|
|
@router.get("/{instanceId}/positions", response_model=PaginatedResponse[TrusteePosition])
|
|
@limiter.limit("30/minute")
|
|
async def get_positions(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
pagination: Optional[str] = Query(None),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> PaginatedResponse[TrusteePosition]:
|
|
"""Get all positions with optional pagination."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
paginationParams = _parsePagination(pagination)
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
result = interface.getAllPositions(paginationParams)
|
|
|
|
if paginationParams:
|
|
return PaginatedResponse(
|
|
items=result.items,
|
|
pagination=PaginationMetadata(
|
|
currentPage=paginationParams.page or 1,
|
|
pageSize=paginationParams.pageSize or 20,
|
|
totalItems=result.totalItems,
|
|
totalPages=result.totalPages,
|
|
sort=paginationParams.sort if paginationParams else [],
|
|
filters=paginationParams.filters if paginationParams else None
|
|
)
|
|
)
|
|
return PaginatedResponse(items=result.items, pagination=None)
|
|
|
|
|
|
@router.get("/{instanceId}/positions/{positionId}", response_model=TrusteePosition)
|
|
@limiter.limit("30/minute")
|
|
async def get_position(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
positionId: str = Path(...),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> TrusteePosition:
|
|
"""Get a single position by ID."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
position = interface.getPosition(positionId)
|
|
if not position:
|
|
raise HTTPException(status_code=404, detail=f"Position {positionId} not found")
|
|
return position
|
|
|
|
|
|
@router.get("/{instanceId}/positions/contract/{contractId}", response_model=List[TrusteePosition])
|
|
@limiter.limit("30/minute")
|
|
async def get_positions_by_contract(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
contractId: str = Path(...),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> List[TrusteePosition]:
|
|
"""Get all positions for a contract."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
return interface.getPositionsByContract(contractId)
|
|
|
|
|
|
@router.get("/{instanceId}/positions/organisation/{orgId}", response_model=List[TrusteePosition])
|
|
@limiter.limit("30/minute")
|
|
async def get_positions_by_organisation(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
orgId: str = Path(...),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> List[TrusteePosition]:
|
|
"""Get all positions for an organisation."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
return interface.getPositionsByOrganisation(orgId)
|
|
|
|
|
|
@router.post("/{instanceId}/positions", response_model=TrusteePosition, status_code=201)
|
|
@limiter.limit("10/minute")
|
|
async def create_position(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
data: TrusteePosition = Body(...),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> TrusteePosition:
|
|
"""Create a new position."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
result = interface.createPosition(data.model_dump())
|
|
if not result:
|
|
raise HTTPException(status_code=400, detail="Failed to create position")
|
|
return result
|
|
|
|
|
|
@router.put("/{instanceId}/positions/{positionId}", response_model=TrusteePosition)
|
|
@limiter.limit("10/minute")
|
|
async def update_position(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
positionId: str = Path(...),
|
|
data: TrusteePosition = Body(...),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> TrusteePosition:
|
|
"""Update a position."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
existing = interface.getPosition(positionId)
|
|
if not existing:
|
|
raise HTTPException(status_code=404, detail=f"Position {positionId} not found")
|
|
|
|
result = interface.updatePosition(positionId, data.model_dump(exclude={"id"}))
|
|
if not result:
|
|
raise HTTPException(status_code=400, detail="Failed to update position")
|
|
return result
|
|
|
|
|
|
@router.delete("/{instanceId}/positions/{positionId}")
|
|
@limiter.limit("10/minute")
|
|
async def delete_position(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
positionId: str = Path(...),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> Dict[str, Any]:
|
|
"""Delete a position."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
existing = interface.getPosition(positionId)
|
|
if not existing:
|
|
raise HTTPException(status_code=404, detail=f"Position {positionId} not found")
|
|
|
|
success = interface.deletePosition(positionId)
|
|
if not success:
|
|
raise HTTPException(status_code=400, detail="Failed to delete position")
|
|
return {"message": f"Position {positionId} deleted"}
|
|
|
|
|
|
# ===== Position-Document Link Routes =====
|
|
|
|
@router.get("/{instanceId}/position-documents")
|
|
@limiter.limit("30/minute")
|
|
async def get_position_documents(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
pagination: Optional[str] = Query(None),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> Dict[str, Any]:
|
|
"""Get all position-document links with optional pagination.
|
|
|
|
Each item includes _permissions: { canUpdate, canDelete } for row-level permission UI.
|
|
"""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
paginationParams = _parsePagination(pagination)
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
result = interface.getAllPositionDocuments(paginationParams)
|
|
|
|
if paginationParams:
|
|
return {
|
|
"items": result.items,
|
|
"pagination": {
|
|
"currentPage": paginationParams.page or 1,
|
|
"pageSize": paginationParams.pageSize or 20,
|
|
"totalItems": result.totalItems,
|
|
"totalPages": result.totalPages,
|
|
"sort": paginationParams.sort if paginationParams else [],
|
|
"filters": paginationParams.filters if paginationParams else None
|
|
}
|
|
}
|
|
return {"items": result.items, "pagination": None}
|
|
|
|
|
|
@router.get("/{instanceId}/position-documents/{linkId}", response_model=TrusteePositionDocument)
|
|
@limiter.limit("30/minute")
|
|
async def get_position_document(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
linkId: str = Path(...),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> TrusteePositionDocument:
|
|
"""Get a single position-document link by ID."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
link = interface.getPositionDocument(linkId)
|
|
if not link:
|
|
raise HTTPException(status_code=404, detail=f"Link {linkId} not found")
|
|
return link
|
|
|
|
|
|
@router.get("/{instanceId}/position-documents/position/{positionId}", response_model=List[TrusteePositionDocument])
|
|
@limiter.limit("30/minute")
|
|
async def get_documents_for_position(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
positionId: str = Path(...),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> List[TrusteePositionDocument]:
|
|
"""Get all document links for a position."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
return interface.getDocumentsForPosition(positionId)
|
|
|
|
|
|
@router.get("/{instanceId}/position-documents/document/{documentId}", response_model=List[TrusteePositionDocument])
|
|
@limiter.limit("30/minute")
|
|
async def get_positions_for_document(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
documentId: str = Path(...),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> List[TrusteePositionDocument]:
|
|
"""Get all position links for a document."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
return interface.getPositionsForDocument(documentId)
|
|
|
|
|
|
@router.post("/{instanceId}/position-documents", response_model=TrusteePositionDocument, status_code=201)
|
|
@limiter.limit("10/minute")
|
|
async def create_position_document(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
data: TrusteePositionDocument = Body(...),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> TrusteePositionDocument:
|
|
"""Create a new position-document link."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
result = interface.createPositionDocument(data.model_dump())
|
|
if not result:
|
|
raise HTTPException(status_code=400, detail="Failed to create link")
|
|
return result
|
|
|
|
|
|
@router.put("/{instanceId}/position-documents/{linkId}", response_model=TrusteePositionDocument)
|
|
@limiter.limit("10/minute")
|
|
async def update_position_document(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
linkId: str = Path(...),
|
|
data: TrusteePositionDocument = Body(...),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> TrusteePositionDocument:
|
|
"""Update a position-document link."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
result = interface.updatePositionDocument(linkId, data.model_dump(exclude_unset=True))
|
|
if not result:
|
|
raise HTTPException(status_code=400, detail="Failed to update link")
|
|
return result
|
|
|
|
|
|
@router.delete("/{instanceId}/position-documents/{linkId}")
|
|
@limiter.limit("10/minute")
|
|
async def delete_position_document(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
linkId: str = Path(...),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> Dict[str, Any]:
|
|
"""Delete a position-document link."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
|
existing = interface.getPositionDocument(linkId)
|
|
if not existing:
|
|
raise HTTPException(status_code=404, detail=f"Link {linkId} not found")
|
|
|
|
success = interface.deletePositionDocument(linkId)
|
|
if not success:
|
|
raise HTTPException(status_code=400, detail="Failed to delete link")
|
|
return {"message": f"Link {linkId} deleted"}
|
|
|
|
|
|
# ===== Instance Roles Management =====
|
|
# These endpoints allow feature admins to manage instance-specific roles and their AccessRules
|
|
|
|
from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext
|
|
|
|
|
|
async def _validateInstanceAdmin(instanceId: str, context: RequestContext) -> str:
|
|
"""
|
|
Validate that the user has admin access to the feature instance.
|
|
Returns the mandateId if authorized.
|
|
|
|
This checks for the RESOURCE permission 'instance-roles.manage'.
|
|
"""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
|
|
# SysAdmin always has access
|
|
if context.user.isSysAdmin:
|
|
return mandateId
|
|
|
|
# Check for instance-roles.manage resource permission
|
|
featureInterface = getFeatureInterface()
|
|
permissions = featureInterface.getUserPermissionsForInstance(context.user.id, instanceId)
|
|
|
|
if not permissions:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Keine Berechtigung zur Rollenverwaltung"
|
|
)
|
|
|
|
# Check for resource permission
|
|
resourcePermissions = permissions.get("resources", {})
|
|
if not resourcePermissions.get("instance-roles.manage"):
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Keine Berechtigung zur Rollenverwaltung"
|
|
)
|
|
|
|
return mandateId
|
|
|
|
|
|
@router.get("/{instanceId}/instance-roles", response_model=PaginatedResponse)
|
|
@limiter.limit("30/minute")
|
|
async def get_instance_roles(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> PaginatedResponse:
|
|
"""
|
|
Get all roles for this feature instance.
|
|
Requires feature admin permission.
|
|
"""
|
|
mandateId = await _validateInstanceAdmin(instanceId, context)
|
|
|
|
rootInterface = getRootInterface()
|
|
|
|
# Get instance-specific roles (mandateId set, featureInstanceId matches)
|
|
roles = rootInterface.db.getRecordset(
|
|
Role,
|
|
recordFilter={
|
|
"featureCode": "trustee",
|
|
"featureInstanceId": instanceId
|
|
}
|
|
)
|
|
|
|
return PaginatedResponse(
|
|
items=roles,
|
|
pagination=None
|
|
)
|
|
|
|
|
|
@router.get("/{instanceId}/instance-roles/{roleId}", response_model=Dict[str, Any])
|
|
@limiter.limit("30/minute")
|
|
async def get_instance_role(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
roleId: str = Path(..., description="Role ID"),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> Dict[str, Any]:
|
|
"""Get a specific instance role."""
|
|
mandateId = await _validateInstanceAdmin(instanceId, context)
|
|
|
|
rootInterface = getRootInterface()
|
|
roles = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId})
|
|
|
|
if not roles:
|
|
raise HTTPException(status_code=404, detail=f"Role {roleId} not found")
|
|
|
|
role = roles[0]
|
|
|
|
# Verify role belongs to this instance
|
|
if role.get("featureInstanceId") != instanceId:
|
|
raise HTTPException(status_code=404, detail=f"Role {roleId} not found in this instance")
|
|
|
|
return role
|
|
|
|
|
|
@router.get("/{instanceId}/instance-roles/{roleId}/rules", response_model=PaginatedResponse)
|
|
@limiter.limit("30/minute")
|
|
async def get_instance_role_rules(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
roleId: str = Path(..., description="Role ID"),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> PaginatedResponse:
|
|
"""
|
|
Get all AccessRules for a specific instance role.
|
|
Requires feature admin permission.
|
|
"""
|
|
mandateId = await _validateInstanceAdmin(instanceId, context)
|
|
|
|
rootInterface = getRootInterface()
|
|
|
|
# Verify role belongs to this instance
|
|
roles = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId})
|
|
if not roles or roles[0].get("featureInstanceId") != instanceId:
|
|
raise HTTPException(status_code=404, detail=f"Role {roleId} not found in this instance")
|
|
|
|
# Get AccessRules for this role
|
|
rules = rootInterface.db.getRecordset(
|
|
AccessRule,
|
|
recordFilter={"roleId": roleId}
|
|
)
|
|
|
|
return PaginatedResponse(
|
|
items=rules,
|
|
pagination=None
|
|
)
|
|
|
|
|
|
@router.post("/{instanceId}/instance-roles/{roleId}/rules", response_model=Dict[str, Any], status_code=201)
|
|
@limiter.limit("10/minute")
|
|
async def create_instance_role_rule(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
roleId: str = Path(..., description="Role ID"),
|
|
ruleData: Dict[str, Any] = Body(...),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Create a new AccessRule for an instance role.
|
|
Requires feature admin permission.
|
|
"""
|
|
mandateId = await _validateInstanceAdmin(instanceId, context)
|
|
|
|
rootInterface = getRootInterface()
|
|
|
|
# Verify role belongs to this instance
|
|
roles = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId})
|
|
if not roles or roles[0].get("featureInstanceId") != instanceId:
|
|
raise HTTPException(status_code=404, detail=f"Role {roleId} not found in this instance")
|
|
|
|
# Create the rule
|
|
try:
|
|
contextStr = ruleData.get("context", "UI")
|
|
if isinstance(contextStr, str):
|
|
contextEnum = AccessRuleContext(contextStr.upper())
|
|
else:
|
|
contextEnum = contextStr
|
|
|
|
newRule = AccessRule(
|
|
roleId=roleId,
|
|
context=contextEnum,
|
|
item=ruleData.get("item"),
|
|
view=ruleData.get("view", False),
|
|
read=ruleData.get("read"),
|
|
create=ruleData.get("create"),
|
|
update=ruleData.get("update"),
|
|
delete=ruleData.get("delete"),
|
|
)
|
|
|
|
created = rootInterface.db.recordCreate(AccessRule, newRule.model_dump())
|
|
return created
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error creating AccessRule: {e}")
|
|
raise HTTPException(status_code=400, detail=f"Failed to create rule: {str(e)}")
|
|
|
|
|
|
@router.put("/{instanceId}/instance-roles/{roleId}/rules/{ruleId}", response_model=Dict[str, Any])
|
|
@limiter.limit("10/minute")
|
|
async def update_instance_role_rule(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
roleId: str = Path(..., description="Role ID"),
|
|
ruleId: str = Path(..., description="Rule ID"),
|
|
ruleData: Dict[str, Any] = Body(...),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Update an AccessRule for an instance role.
|
|
Only view, read, create, update, delete can be changed.
|
|
Requires feature admin permission.
|
|
"""
|
|
mandateId = await _validateInstanceAdmin(instanceId, context)
|
|
|
|
rootInterface = getRootInterface()
|
|
|
|
# Verify role belongs to this instance
|
|
roles = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId})
|
|
if not roles or roles[0].get("featureInstanceId") != instanceId:
|
|
raise HTTPException(status_code=404, detail=f"Role {roleId} not found in this instance")
|
|
|
|
# Verify rule belongs to role
|
|
existingRules = rootInterface.db.getRecordset(AccessRule, recordFilter={"id": ruleId})
|
|
if not existingRules or existingRules[0].get("roleId") != roleId:
|
|
raise HTTPException(status_code=404, detail=f"Rule {ruleId} not found for this role")
|
|
|
|
# Update only allowed fields
|
|
updateData = {}
|
|
if "view" in ruleData:
|
|
updateData["view"] = ruleData["view"]
|
|
if "read" in ruleData:
|
|
updateData["read"] = ruleData["read"]
|
|
if "create" in ruleData:
|
|
updateData["create"] = ruleData["create"]
|
|
if "update" in ruleData:
|
|
updateData["update"] = ruleData["update"]
|
|
if "delete" in ruleData:
|
|
updateData["delete"] = ruleData["delete"]
|
|
|
|
if not updateData:
|
|
return existingRules[0]
|
|
|
|
try:
|
|
updated = rootInterface.db.recordModify(AccessRule, ruleId, updateData)
|
|
return updated
|
|
except Exception as e:
|
|
logger.error(f"Error updating AccessRule: {e}")
|
|
raise HTTPException(status_code=400, detail=f"Failed to update rule: {str(e)}")
|
|
|
|
|
|
@router.delete("/{instanceId}/instance-roles/{roleId}/rules/{ruleId}")
|
|
@limiter.limit("10/minute")
|
|
async def delete_instance_role_rule(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
roleId: str = Path(..., description="Role ID"),
|
|
ruleId: str = Path(..., description="Rule ID"),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Delete an AccessRule for an instance role.
|
|
Requires feature admin permission.
|
|
"""
|
|
mandateId = await _validateInstanceAdmin(instanceId, context)
|
|
|
|
rootInterface = getRootInterface()
|
|
|
|
# Verify role belongs to this instance
|
|
roles = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId})
|
|
if not roles or roles[0].get("featureInstanceId") != instanceId:
|
|
raise HTTPException(status_code=404, detail=f"Role {roleId} not found in this instance")
|
|
|
|
# Verify rule belongs to role
|
|
existingRules = rootInterface.db.getRecordset(AccessRule, recordFilter={"id": ruleId})
|
|
if not existingRules or existingRules[0].get("roleId") != roleId:
|
|
raise HTTPException(status_code=404, detail=f"Rule {ruleId} not found for this role")
|
|
|
|
try:
|
|
rootInterface.db.recordDelete(AccessRule, ruleId)
|
|
return {"message": f"Rule {ruleId} deleted"}
|
|
except Exception as e:
|
|
logger.error(f"Error deleting AccessRule: {e}")
|
|
raise HTTPException(status_code=400, detail=f"Failed to delete rule: {str(e)}")
|